@jayyuen66/dsh-ocr-review 0.1.6 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +289 -0
- package/LICENSE +1 -1
- package/README.md +203 -163
- package/README.zh-CN.md +209 -170
- package/THIRD_PARTY_NOTICES.md +10 -7
- package/client.js +477 -134
- package/cordis.patch.yml +4 -2
- package/host.js +2382 -624
- package/package.json +46 -46
- package/scripts/get-cred.mjs +156 -56
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: " …[中间省略]… ",
|
|
@@ -41,19 +41,32 @@ const MESSAGES = {
|
|
|
41
41
|
missingRequiredArgs: "ocr {name}: 缺少必填参数 {keys}(缺失即拒绝,不产生副作用)",
|
|
42
42
|
bodyMustBeObject: "body 必须是 JSON 对象",
|
|
43
43
|
outputFileMissing: "ocr 未写出 --output 文件({path}):{cause}——请确认 ocr 版本支持 --output,或用 ocr_session 查询会话结果",
|
|
44
|
+
outputFileTooLarge: "ocr 的 --output 文件过大({path},{size} 字节 > {limit} 字节上限):不整份读进内存。请缩小审查范围(exclude / --commit / 更小的目录)后重试,或用 ocr_session 查这一场的结果",
|
|
45
|
+
failureCoverage: "\n这一场未完成的文件(取自 OCR 已发布的 --output):{items};完整清单见 ocr_session(action=comments/show)",
|
|
44
46
|
settingsServiceGone: "{ns} 所在的设置服务在读取过程中已不可用",
|
|
45
47
|
repoRequired: "repo 必填,且为 Git 仓库绝对路径",
|
|
46
48
|
repoRequiredUnknownWorkspace: "repo 必填:当前会话工作区未知,请显式传入 Git 仓库绝对路径",
|
|
47
|
-
repoMustBeAbsolute: "repo
|
|
49
|
+
repoMustBeAbsolute: "repo 必须是绝对路径:{root}",
|
|
48
50
|
noNulBytes: "{name} 不能包含 NUL 字节",
|
|
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/目录拆成多批分别调用",
|
|
49
60
|
mustBeString: "{name} 必须是字符串(收到 {received})",
|
|
50
61
|
listItemsMustBeString: "{name} 数组元素必须是字符串",
|
|
51
62
|
mustBeStringOrList: "{name} 必须是字符串或字符串数组",
|
|
52
63
|
intFlagMustBeInt: "{flag} 需要为 ≥{min} 的整数,收到 {received}",
|
|
64
|
+
formatNotJson: "本包只支持 --format json(收到 {format}):text/sarif 没有解析层,交下去只会把参数错报成「OCR 输出解析失败」",
|
|
53
65
|
scopeBranchCommitExclusive: "scope=branch/from/to 与 commit 互斥(一次只能选一种审查范围)",
|
|
54
66
|
branchNeedsFromAndTo: "scope=branch 需要同时提供 from 与 to",
|
|
55
67
|
commitNeedsValue: "scope=commit 需要 commit 参数",
|
|
56
68
|
unknownScope: "未知 scope:{scope}(workspace/commit/branch)",
|
|
69
|
+
scopeMismatch: "scope={scope} 与 {given} 不能同时给(scope 是声明,{given} 属于另一种范围;两者冲突时按哪个审没有正确答案)",
|
|
57
70
|
resumeNeedsScope: "resume 需要 scope=commit 或 scope=branch(workspace 工作区评审不支持恢复,OCR 会直接拒绝);请同时给出 commit 或 from/to",
|
|
58
71
|
waitMustBeBoolean: "wait 必须是布尔值(true/false),收到 {received}",
|
|
59
72
|
pathsRequired: "paths 必填:至少传一个待审查文件的仓库相对路径",
|
|
@@ -62,16 +75,41 @@ const MESSAGES = {
|
|
|
62
75
|
listRejectsId: "action=list 不接受 id(查单个会话请用 action=show 或 action=comments)",
|
|
63
76
|
resultSchemaViolation: "ocr {tool}: 返回值不符合该工具的 output schema:{violations}",
|
|
64
77
|
resultSchemaUnsupported: "ocr {tool}: 返回形态守卫不支持 output schema 关键字 {keyword}(插件内 bug:lib/output.ts 的关键字子集没跟上 schema)",
|
|
78
|
+
resultSchemaMalformed: "ocr {tool}: output schema 自身不合法——{detail}(插件内 bug:schema 的写法超出了返回形态守卫支持的官方子集)",
|
|
65
79
|
ocrNotJson: "OCR 未返回合法 JSON 输出",
|
|
66
80
|
ocrNotObject: "OCR JSON 输出不是对象",
|
|
67
81
|
ocrStatusInvalid: "OCR 输出 status 非法或缺失:{status}(期望 {expected});exit 0 不代表评审完成,请查 stderr 与 ~/.opencodereview/sessions/",
|
|
68
82
|
statusNone: "(无)",
|
|
83
|
+
ocrStatusFailed: "OCR 回执自报 status=failed(这一场评审没有成功完成)",
|
|
69
84
|
ocrCommentsNotArray: "OCR 输出 comments 字段不是数组,无法确认评论完整性",
|
|
70
85
|
warningSeparator: ":",
|
|
71
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」当作「代码干净」。",
|
|
72
87
|
lineZeroNote: "(line 0: 定位失败,按 existing_code 手动定位)",
|
|
73
88
|
parseFailedWithReason: "{tool} 输出解析失败:{message}\n原始内容前 {limit} 码元(已脱敏):\n{raw}",
|
|
74
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 里,直接用浏览器打开即可(文件留在临时目录,不会自动删除)。",
|
|
75
113
|
sourceSettingsUnavailable: "dsh 设置服务(ctx.settings)未装配:本插件需要它读取已配置的 provider 列表",
|
|
76
114
|
sourceSettingsFailed: "调用 dsh 设置服务失败:{message}",
|
|
77
115
|
sourceNamespaceUnavailable: "dsh 配置里没有 {ns} 命名空间:未安装 pi-ai 适配器,或还没配置任何 provider",
|
|
@@ -79,6 +117,7 @@ const MESSAGES = {
|
|
|
79
117
|
badEnvKey: "非法 env key:{key}(get-cred 只接受 ^[A-Z0-9_]+$ 的大写 ref 名;请在 dsh 配置里把该 provider 的 apiKeyEnv 改成大写字母/数字/下划线)",
|
|
80
118
|
configNotJson: "{where} 不是合法 JSON,已中止写入以保护既有配置(请先修复或删掉该文件):{cause}",
|
|
81
119
|
configNotObject: "{where} 顶层不是 JSON 对象,已中止写入以保护既有配置",
|
|
120
|
+
configUnreadable: "{where} 读不动,已中止写入以保护既有配置(既有文件不会被覆盖):{cause}",
|
|
82
121
|
providerNotInList: "provider 不在 dsh 配置的 {ns}.providers 列表:{provider}",
|
|
83
122
|
modelNotInList: "model 不在 {provider} 的 models 列表中:{model}",
|
|
84
123
|
protocolUnsupported: "provider {provider} 的 api 协议 {protocol} 无法映射到 OCR 支持的 protocol(openai/openai-responses/anthropic);请在 dsh 配置里改用 openai-completions/openai-responses/anthropic-messages,或升级本插件",
|
|
@@ -92,16 +131,19 @@ const MESSAGES = {
|
|
|
92
131
|
warningUnreadable: "(一条格式无法解析的 warning:既不是字符串,也没有 file/message/type 字段)",
|
|
93
132
|
diagnosticUnreadable: "(OCR 的 {field} 字段形态不合,这一场的诊断数据读不出来)",
|
|
94
133
|
diagnosticOmitted: "(另有 {count} 条同类明细已省略,全量见 ocr_session 的 show/comments)",
|
|
95
|
-
commaInListItem: "{name} 的数组形式里有一项自身含逗号:{item}
|
|
134
|
+
commaInListItem: "{name} 的数组形式里有一项自身含逗号:{item}。值最终按逗号拼接传给 ocr,含逗号的模式必被拆开、静默改变审查/过滤范围。请改写成不含逗号的等价模式(如把 {ts,tsx} 展开成两条),或去掉该条",
|
|
135
|
+
commaInStringBraces: "{name} 的字符串形式里有 glob 花括号内含逗号:{item}。按逗号拆分后花括号会被撕成多个无效模式,过滤条件失效、审查范围被无声扩大。请把花括号展开成多条模式({ts,tsx} 即两条)后再传入",
|
|
96
136
|
probeNoVersion: "ocr version 跑通了(exit 0),但回执里读不出版本号:那通常是未发布构建。",
|
|
97
137
|
probeTimedOut: "ocr version 超时({seconds} 秒内没回来):那条进程已被收走。稍后重试,或直接在终端里跑 ocr version——本插件不会替它代跑安装或升级。",
|
|
98
138
|
providerModelRequired: "provider 与 model 必填",
|
|
99
139
|
bodyParseFailed: "body 解析失败:{message}",
|
|
100
|
-
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
|
|
101
|
-
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 根内文件。",
|
|
102
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)。",
|
|
103
143
|
ruleToolDescription: "open-code-review 委托模式第 2 步:按文件路径返回适配的审查规则(按规则内容分组,共享规则的多个文件归一组)。把 ocr_delegate_preview 得到的 reviewable 路径传进来,作为你自己审查时的检查清单。规则文本较长时按批传入。",
|
|
104
|
-
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 登录)。不发起评审、不测试连通性。",
|
|
105
147
|
paramRepo: "Git 仓库绝对路径(可选:缺省=当前会话工作区)",
|
|
106
148
|
paramScope: "审查范围(缺省 workspace)",
|
|
107
149
|
paramCommit: "scope=commit 时必填:提交哈希",
|
|
@@ -110,41 +152,64 @@ const MESSAGES = {
|
|
|
110
152
|
paramCommitShort: "scope=commit 时必填",
|
|
111
153
|
paramFromShort: "scope=branch 时必填",
|
|
112
154
|
paramToShort: "scope=branch 时必填",
|
|
113
|
-
paramEffort: "
|
|
155
|
+
paramEffort: "投入档位 low/medium/high;不给时取设置卡默认(出厂 auto=跟随 OCR 自身配置)",
|
|
114
156
|
paramBackground: "需求/业务上下文(官方 skill 推荐:调用前先用 git log/diff/提交信息快速分析变更意图,提取 3-5 句业务背景传入,是提升评审质量最有效的参数;从其它 agent 调用时始终携带 PR 描述/需求)",
|
|
115
157
|
paramBackgroundShort: "需求/业务上下文",
|
|
116
158
|
paramBackgroundPreview: "业务上下文(会出现在输出中供审查参考)",
|
|
117
|
-
paramExclude: "gitignore
|
|
118
|
-
paramExcludeShort: "
|
|
119
|
-
paramWait: "
|
|
159
|
+
paramExclude: "gitignore 风格排除模式(可多值;上限 2000 条,超出会直接报错而不是静默丢弃)",
|
|
160
|
+
paramExcludeShort: "排除模式(上限 2000 条,超出会报错)",
|
|
161
|
+
paramWait: "false(缺省)=后台启动、立即返回作业 id,用 ocr_session 轮询结果——一次 review 是分钟级长任务,阻塞等待会占住整轮;true=前台阻塞到完成并直接返回聚合摘要。后台模式的兜底窗口由设置项 ocrBackgroundMaxMs 给出(默认 0 = 不设窗口)。两种模式都带宿主回收守护:dsh 退出(含 kill -9)时 OCR 进程一并结束",
|
|
120
162
|
paramProvider: "本次运行临时指定已配置 provider",
|
|
121
163
|
paramModel: "本次运行临时覆盖 model",
|
|
122
164
|
paramResume: "从之前中断的会话 id 恢复(会话 id 在返回结果里)",
|
|
123
|
-
paramScanPath: "
|
|
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 恢复",
|
|
124
175
|
paramBatch: "分批策略",
|
|
125
176
|
paramPaths: "待查询的仓库相对文件路径(至少 1 个,最多 200)",
|
|
126
|
-
paramSessionAction: "list:列最近会话(缺省)/ show:查看单个 / comments:取评论",
|
|
127
|
-
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)",
|
|
128
183
|
paramSessionLimit: "list 上限 1-100(缺省 10)",
|
|
184
|
+
paramRulesPath: "待查询的文件路径(仓库相对或绝对;仅用于规则匹配)",
|
|
185
|
+
paramLlmAction: "providers:内置 provider 列表 / models:ChatGPT 账号模型列表",
|
|
129
186
|
unitGroup: "组",
|
|
130
187
|
unitFile: "文件",
|
|
131
188
|
tuningConcurrency: "并发{unit}数(OCR --concurrency;缺省不传=OCR 原生 8)",
|
|
132
|
-
tuningTimeout: "每{unit}任务超时分钟(OCR --timeout;0=不限时;缺省不传=OCR 原生 15)",
|
|
189
|
+
tuningTimeout: "每{unit}任务超时分钟(OCR --timeout;review 会按 effort 轮数线性放大,scan 不放大;0=不限时;缺省不传=OCR 原生 15)",
|
|
133
190
|
tuningMaxTools: "每{unit}工具调用轮数上限(OCR --max-tools;0=模板默认;1-49 会被 OCR 抬到 50,帮助文本 min 50;缺省不传=模板默认)",
|
|
191
|
+
tuningMaxToolsScan: "每{unit}工具调用轮数上限(OCR --max-tools;0=模板默认;只在大于模板默认值时生效,不抬到 50;缺省不传=模板默认)",
|
|
134
192
|
tuningMaxTokens: "每{unit}提示词 token 上限(OCR --max-tokens;0=配置/模板默认;缺省不传=模板默认)",
|
|
135
193
|
tuningMaxTokensBudget: "本次运行 total token(input+output)预算闸门,超出即停止派发部分结果(OCR --max-tokens-budget;0=不限;缺省不传=不限)",
|
|
136
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 缩小范围。",
|
|
137
196
|
ruleHint: "按分组规则逐个审查对应文件,输出评论建议格式:{path, content, start_line, end_line, category, severity}。",
|
|
138
197
|
sessionEmpty: "(暂无历史评审会话:先跑一次 ocr_review/ocr_scan 再查询)",
|
|
139
198
|
sessionTruncated: "…[截断] 完整内容见 ~/.opencodereview/sessions/",
|
|
140
|
-
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 配置为准。",
|
|
141
202
|
routingText: `
|
|
142
203
|
- 用户要求「审查代码/Review/检查我的改动/审这个 commit/PR」时,用 ocr 工具做代码审查:
|
|
143
204
|
- ocr_review:OCR 用自己配置的 LLM 独立审查工作区/commit/分支变更(effort 控制深度,可给 background 上下文)。
|
|
144
205
|
- ocr_scan:对无 git 历史的目录或指定路径做全文件审查。
|
|
145
206
|
- ocr_delegate_preview / ocr_delegate_rule:只取「文件清单 + 适配规则」的确定性脚手架(OCR 端零 LLM 消耗),
|
|
146
207
|
配合 git diff 由你自己完成审查——适合复用本会话模型能力、或先确认审查范围再决定是否跑 ocr_review。
|
|
147
|
-
-
|
|
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 拆。
|
|
148
213
|
- OCR 端点/provider/model 由「设置 → 插件 → ocr-review 卡片」从宿主设置服务的 provider 清单选择
|
|
149
214
|
(key 只按 api_key_cmd 动态读取,不落盘明文);也可在工具参数里用 provider/model 逐次覆盖。
|
|
150
215
|
- 调用 ocr_review / ocr_scan 前,先用 git log/diff/提交信息快速分析变更意图,提取 3-5 句业务上下文
|
|
@@ -157,7 +222,7 @@ const MESSAGES = {
|
|
|
157
222
|
- LLM 调用本身有重试:OCR 内置重试(429/408/409 与 5xx 自动退避重试);per-file 组失败被隔离,进
|
|
158
223
|
coverage.failedItems(产出里没有 manifest 时进 warnings),不整体重跑。
|
|
159
224
|
工具层超时(min(请求, shell 执行器自己设置项里的 maxTimeoutMs),缺省 10 分钟)或部分文件失败后,可
|
|
160
|
-
ocr_session 查 session_id、用 ocr_review --resume 恢复中断的区间/commit
|
|
225
|
+
ocr_session 查 session_id、用 ocr_review --resume 恢复中断的区间/commit 评审,或 ocr_scan --resume 续扫。
|
|
161
226
|
`.trim()
|
|
162
227
|
},
|
|
163
228
|
en: {
|
|
@@ -185,19 +250,32 @@ const MESSAGES = {
|
|
|
185
250
|
missingRequiredArgs: "ocr {name}: missing required parameter(s) {keys} (rejected outright, no side effects)",
|
|
186
251
|
bodyMustBeObject: "body must be a JSON object",
|
|
187
252
|
outputFileMissing: "ocr did not write the --output file ({path}): {cause} — check that your ocr version supports --output, or query the session with ocr_session",
|
|
253
|
+
outputFileTooLarge: "the ocr --output file is too large ({path}, {size} bytes > {limit}-byte cap): refusing to read it whole. Narrow the review scope (exclude / --commit / smaller directory) and retry, or read this run's results with ocr_session",
|
|
254
|
+
failureCoverage: "\nfiles that did not finish in this run (from the --output OCR already published): {items}; full list via ocr_session(action=comments/show)",
|
|
188
255
|
settingsServiceGone: "the settings service holding {ns} became unavailable while being read",
|
|
189
256
|
repoRequired: "repo is required and must be the absolute path of a Git repository",
|
|
190
257
|
repoRequiredUnknownWorkspace: "repo is required: the current session workspace is unknown, pass the absolute path of a Git repository explicitly",
|
|
191
|
-
repoMustBeAbsolute: "repo must be an absolute path
|
|
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",
|
|
192
268
|
noNulBytes: "{name} must not contain NUL bytes",
|
|
193
269
|
mustBeString: "{name} must be a string (got {received})",
|
|
194
270
|
listItemsMustBeString: "the items of {name} must be strings",
|
|
195
271
|
mustBeStringOrList: "{name} must be a string or an array of strings",
|
|
196
272
|
intFlagMustBeInt: "{flag} must be an integer ≥ {min}, got {received}",
|
|
273
|
+
formatNotJson: "this package supports --format json only (got {format}): text/sarif have no parser here, so passing them through reports a bad parameter as an OCR output failure",
|
|
197
274
|
scopeBranchCommitExclusive: "scope=branch/from/to and commit are mutually exclusive (only one review scope at a time)",
|
|
198
275
|
branchNeedsFromAndTo: "scope=branch requires both from and to",
|
|
199
276
|
commitNeedsValue: "scope=commit requires the commit parameter",
|
|
200
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)",
|
|
201
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",
|
|
202
280
|
waitMustBeBoolean: "wait must be a boolean (true/false), got {received}",
|
|
203
281
|
pathsRequired: "paths is required: pass at least one repo-relative path of a file to review",
|
|
@@ -206,16 +284,41 @@ const MESSAGES = {
|
|
|
206
284
|
listRejectsId: "action=list does not take an id (use action=show or action=comments for a single session)",
|
|
207
285
|
resultSchemaViolation: "ocr {tool}: the tool result does not match its output schema: {violations}",
|
|
208
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)",
|
|
209
288
|
ocrNotJson: "OCR returned no valid JSON output",
|
|
210
289
|
ocrNotObject: "the OCR JSON output is not an object",
|
|
211
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/",
|
|
212
291
|
statusNone: "(none)",
|
|
292
|
+
ocrStatusFailed: "the OCR receipt reports status=failed (this review run did not complete)",
|
|
213
293
|
ocrCommentsNotArray: "the comments field of the OCR output is not an array, comment completeness cannot be confirmed",
|
|
214
294
|
warningSeparator: ": ",
|
|
215
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\".",
|
|
216
296
|
lineZeroNote: "(line 0: not anchored, locate it via existing_code)",
|
|
217
297
|
parseFailedWithReason: "{tool} output parsing failed: {message}\nfirst {limit} UTF-16 code units of the raw output (redacted):\n{raw}",
|
|
218
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.",
|
|
219
322
|
sourceSettingsUnavailable: "the dsh settings service (ctx.settings) is not wired up: this plugin needs it to read the configured provider list",
|
|
220
323
|
sourceSettingsFailed: "calling the dsh settings service failed: {message}",
|
|
221
324
|
sourceNamespaceUnavailable: "the dsh configuration has no {ns} namespace: the pi-ai adapter is not installed, or no provider is configured yet",
|
|
@@ -223,6 +326,7 @@ const MESSAGES = {
|
|
|
223
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)",
|
|
224
327
|
configNotJson: "{where} is not valid JSON; the write was aborted to protect the existing configuration (fix or delete that file first): {cause}",
|
|
225
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}",
|
|
226
330
|
providerNotInList: "provider is not in the {ns}.providers list configured by dsh: {provider}",
|
|
227
331
|
modelNotInList: "model is not in the models list of {provider}: {model}",
|
|
228
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",
|
|
@@ -236,16 +340,19 @@ const MESSAGES = {
|
|
|
236
340
|
warningUnreadable: "(one unreadable warning entry: neither a string nor an object with file/message/type)",
|
|
237
341
|
diagnosticUnreadable: "(the {field} field of the OCR output is malformed; its diagnostics cannot be read for this run)",
|
|
238
342
|
diagnosticOmitted: "({count} more entries of the same kind were omitted; use ocr_session show/comments for the full list)",
|
|
239
|
-
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",
|
|
240
345
|
probeNoVersion: "ocr version ran fine (exit 0), yet no version could be read from its output: that usually means an unreleased build",
|
|
241
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",
|
|
242
347
|
providerModelRequired: "provider and model are both required",
|
|
243
348
|
bodyParseFailed: "failed to parse the body: {message}",
|
|
244
|
-
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.",
|
|
245
|
-
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.",
|
|
246
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).",
|
|
247
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.",
|
|
248
|
-
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.",
|
|
249
356
|
paramRepo: "Absolute path to the Git repository (optional: default = current session workspace)",
|
|
250
357
|
paramScope: "Review scope (default workspace)",
|
|
251
358
|
paramCommit: "Required with scope=commit: commit hash",
|
|
@@ -254,34 +361,53 @@ const MESSAGES = {
|
|
|
254
361
|
paramCommitShort: "Required with scope=commit",
|
|
255
362
|
paramFromShort: "Required with scope=branch",
|
|
256
363
|
paramToShort: "Required with scope=branch",
|
|
257
|
-
paramEffort: "Effort level
|
|
364
|
+
paramEffort: "Effort level low/medium/high; when omitted the settings card default applies (factory auto = follow the OCR config)",
|
|
258
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)",
|
|
259
366
|
paramBackgroundShort: "Requirements/business context",
|
|
260
367
|
paramBackgroundPreview: "Business context (it shows up in the output for review reference)",
|
|
261
|
-
paramExclude: "gitignore-style exclusion patterns (multiple allowed)",
|
|
262
|
-
paramExcludeShort: "Exclusion patterns",
|
|
263
|
-
paramWait: "
|
|
368
|
+
paramExclude: "gitignore-style exclusion patterns (multiple allowed; cap 2000 - exceeding it is an error, never a silent drop)",
|
|
369
|
+
paramExcludeShort: "Exclusion patterns (cap 2000; exceeding it errors out)",
|
|
370
|
+
paramWait: "false (default) = start in the background, return the job id immediately and poll with ocr_session - a review is a minutes-long task and blocking the call ties up the whole turn; true = block until it finishes and return the aggregated summary directly. The background backstop window is the ocrBackgroundMaxMs setting (default 0 = none). Both modes carry the host reaper guard: when dsh exits (including kill -9) the OCR process is terminated too",
|
|
264
371
|
paramProvider: "Temporarily use a configured provider for this run",
|
|
265
372
|
paramModel: "Temporarily override the model for this run",
|
|
266
373
|
paramResume: "Resume from a previously interrupted session id (the id is in the result)",
|
|
267
|
-
paramScanPath: "Repo-relative directories or files (default: whole repo, multiple allowed)",
|
|
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",
|
|
268
384
|
paramBatch: "Batching strategy",
|
|
269
385
|
paramPaths: "Repo-relative file paths to look up (at least 1, at most 200)",
|
|
270
|
-
paramSessionAction: "list: recent sessions (default) / show: one session / comments: comment list",
|
|
271
|
-
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)",
|
|
272
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",
|
|
273
395
|
unitGroup: "group",
|
|
274
396
|
unitFile: "file",
|
|
275
397
|
tuningConcurrency: "Concurrent {unit} count (OCR --concurrency; unset = OCR native 8)",
|
|
276
|
-
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)",
|
|
277
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)",
|
|
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)",
|
|
278
401
|
tuningMaxTokens: "Per-{unit} prompt token cap (OCR --max-tokens; 0 = config/template default; unset = template default)",
|
|
279
402
|
tuningMaxTokensBudget: "Total token (input+output) budget gate for this run; dispatch stops once exceeded (OCR --max-tokens-budget; 0 = unlimited; unset = unlimited)",
|
|
280
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.",
|
|
281
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}.",
|
|
282
406
|
sessionEmpty: "(no review sessions yet: run ocr_review/ocr_scan once before querying)",
|
|
283
407
|
sessionTruncated: "…[truncated] full content in ~/.opencodereview/sessions/",
|
|
284
|
-
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.",
|
|
285
411
|
routingText: `
|
|
286
412
|
- When the user asks to "review the code / Review / check my changes / review this commit or PR", use the ocr tools:
|
|
287
413
|
- ocr_review: OCR reviews workspace/commit/branch changes with its own configured LLM (effort controls depth, background carries context).
|
|
@@ -289,7 +415,11 @@ const MESSAGES = {
|
|
|
289
415
|
- ocr_delegate_preview / ocr_delegate_rule: deterministic scaffolding that only returns "file list + fitting rules"
|
|
290
416
|
(zero LLM cost on the OCR side); pair it with git diff and review yourself — good for reusing this session's model,
|
|
291
417
|
or for confirming the scope before deciding to run ocr_review.
|
|
292
|
-
-
|
|
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
|
|
293
423
|
exclude or split by commit.
|
|
294
424
|
- The OCR endpoint/provider/model comes from "Settings → Plugins → ocr-review card", fed by the provider list of
|
|
295
425
|
the host settings service (the key is read dynamically through api_key_cmd and never stored in plaintext);
|
|
@@ -307,7 +437,7 @@ const MESSAGES = {
|
|
|
307
437
|
isolated and land in coverage.failedItems (in warnings when the output carries no manifest) instead of re-running
|
|
308
438
|
everything. After a tool-layer timeout (min(request, the maxTimeoutMs setting of the shell executor itself,
|
|
309
439
|
10 minutes by default) or partial file failures, use ocr_session to find the session_id and ocr_review --resume
|
|
310
|
-
to continue an interrupted range/commit review.
|
|
440
|
+
to continue an interrupted range/commit review, or ocr_scan --resume to continue a scan.
|
|
311
441
|
`.trim()
|
|
312
442
|
}
|
|
313
443
|
};
|
|
@@ -323,8 +453,7 @@ const PLACEHOLDER = /\{(?<key>\w+)\}/gu;
|
|
|
323
453
|
function format(template, params) {
|
|
324
454
|
return template.replaceAll(PLACEHOLDER, (_all, key) => params[key] ?? "");
|
|
325
455
|
}
|
|
326
|
-
|
|
327
|
-
//#region lib/output.ts
|
|
456
|
+
// lib/output.ts
|
|
328
457
|
/** 守卫支持的 schema 关键字(官方 enforced subset 的全部关键字,见
|
|
329
458
|
* installed dsh-tools lib/index.js:206;description/title/default/examples 是
|
|
330
459
|
* 官方同样忽略的注解)。 */
|
|
@@ -342,14 +471,77 @@ const SUPPORTED_KEYWORDS = /* @__PURE__ */ new Set([
|
|
|
342
471
|
"default",
|
|
343
472
|
"examples"
|
|
344
473
|
]);
|
|
345
|
-
/**
|
|
346
|
-
|
|
347
|
-
|
|
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) {
|
|
348
503
|
for (const keyword of Object.keys(schema)) if (!SUPPORTED_KEYWORDS.has(keyword)) throw new Error(format(messages.resultSchemaUnsupported, {
|
|
349
504
|
tool: toolName,
|
|
350
505
|
keyword
|
|
351
506
|
}));
|
|
352
|
-
|
|
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);
|
|
353
545
|
for (const property of Object.values(schema.properties ?? {})) assertGuardable(property, toolName, messages);
|
|
354
546
|
const { items } = schema;
|
|
355
547
|
if (items !== void 0) assertGuardable(items, toolName, messages);
|
|
@@ -411,8 +603,20 @@ const AUTH_HEADER_VALUE = /(?<name>\b(?:proxy-)?authorization["'\s]*[:=]["'\s]*)
|
|
|
411
603
|
const BEARER_VALUE = /(?<scheme>\bbearer[ \t]+)[a-z0-9._~+/=-]{4,}/giu;
|
|
412
604
|
/** `api_key: "xxx"`、`apiKey=xxx`、`token: xxx`、`x-api-key: xxx` 等赋值形态。
|
|
413
605
|
* 值下限取 4 且容许一个 scheme 词:`token: Basic <base64>` 里赋值形态先能吃到的只是
|
|
414
|
-
* scheme 词(Basic 恰好 5 字符),下限更高时整条失配,后面那个 base64 就原样进了日志。
|
|
415
|
-
|
|
606
|
+
* scheme 词(Basic 恰好 5 字符),下限更高时整条失配,后面那个 base64 就原样进了日志。
|
|
607
|
+
*
|
|
608
|
+
* 左边界用 `(?<![A-Za-z0-9])` 而**不是** `\b`:`_` 也属 `\w`,`DEEPSEEK_API_KEY=` 里
|
|
609
|
+
* `KEY` 之前的那个位置对 `\b` 不成立,于是最常见的环境变量写法整条失配——而「把
|
|
610
|
+
* `XXX_API_KEY=…` 抹掉」正是这道闸最该管的一类。
|
|
611
|
+
*
|
|
612
|
+
* 字符类写 `[A-Z0-9]` 而不是 `[A-Za-z0-9]`:这条正则带 `i` 标志,两者等价,
|
|
613
|
+
* 而重复的字符区间会让静态检查报 duplicates-in-character-class。 */
|
|
614
|
+
const KEY_ASSIGNMENT = /(?<name>(?<![A-Z0-9])(?:api[_-]?key|apikey|access[_-]?key|secret[_-]?key|client[_-]?secret|password|token)["'\s]*[:=]["'\s]*)(?:(?<scheme>[a-z][a-z0-9_-]*[ \t]+))?[^\s"',;)}\]]{4,}/giu;
|
|
615
|
+
/** GitHub 系 token 的**下划线**形态(`ghp_…`、`github_pat_…`):STANDALONE_KEY 要求
|
|
616
|
+
* 「字母前缀 + 连字符」分界,这类值里没有连字符,前缀后又紧跟下划线,故单列一条。
|
|
617
|
+
* 不并进 STANDALONE_KEY 是为了不放开那里的连字符要求——那条要求同时承担「别把
|
|
618
|
+
* kebab 小写路径当 key 抹掉」的职责。 */
|
|
619
|
+
const UNDERSCORE_TOKEN = /\b(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{20,}\b/gu;
|
|
416
620
|
const REDACTION = "***REDACTED***";
|
|
417
621
|
/** 独立 key 形态的替换臂:随机串段里真有数字或大写才抹,否则原样交回(文件路径要保持可读)。 */
|
|
418
622
|
function redactStandaloneKey(match) {
|
|
@@ -431,7 +635,7 @@ function redactStandaloneKey(match) {
|
|
|
431
635
|
* 要求随机串特征,避免把要交给模型的文件路径一起抹掉。
|
|
432
636
|
*/
|
|
433
637
|
function redactKeyMaterial(text) {
|
|
434
|
-
return text.replaceAll(AUTH_HEADER_VALUE, `$<name>$<scheme>${REDACTION}`).replaceAll(BEARER_VALUE, `$<scheme>${REDACTION}`).replaceAll(KEY_ASSIGNMENT, `$<name>$<scheme>${REDACTION}`).replaceAll(STANDALONE_KEY, redactStandaloneKey);
|
|
638
|
+
return text.replaceAll(AUTH_HEADER_VALUE, `$<name>$<scheme>${REDACTION}`).replaceAll(BEARER_VALUE, `$<scheme>${REDACTION}`).replaceAll(KEY_ASSIGNMENT, `$<name>$<scheme>${REDACTION}`).replaceAll(UNDERSCORE_TOKEN, REDACTION).replaceAll(STANDALONE_KEY, redactStandaloneKey);
|
|
435
639
|
}
|
|
436
640
|
/** ECMA-48 的 CSI 序列:ESC `[` + 参数位 + 中间位 + 终结位(SGR 颜色码就是终结位 `m`)。 */
|
|
437
641
|
const ANSI_CSI = new RegExp(`${String.fromCodePoint(27)}\\[[0-?]*[ -/]*[@-~]`, "gu");
|
|
@@ -456,8 +660,8 @@ function violationsOf(schema, value, path) {
|
|
|
456
660
|
if (passing.length !== 1) violations.push(`${path}: matched ${passing.length} of ${oneOf.length} oneOf branches`);
|
|
457
661
|
}
|
|
458
662
|
const { type } = schema;
|
|
459
|
-
if (type === "object")
|
|
460
|
-
else if (type === "array")
|
|
663
|
+
if (type === "object") for (const violation of objectViolations(schema, value, path, violationsOf)) violations.push(violation);
|
|
664
|
+
else if (type === "array") for (const violation of arrayViolations(schema, value, path, violationsOf)) violations.push(violation);
|
|
461
665
|
else if (type !== void 0 && scalarMismatch(type, value)) violations.push(`${path}: expected ${type}`);
|
|
462
666
|
if (schema.const !== void 0 && value !== schema.const) violations.push(`${path}: must be exactly ${JSON.stringify(schema.const)}`);
|
|
463
667
|
if (schema.enum !== void 0 && !schema.enum.includes(value)) violations.push(`${path}: must be one of ${schema.enum.map((entry) => JSON.stringify(entry)).join("/")}`);
|
|
@@ -488,8 +692,7 @@ function assertToolOutput(schema, value, toolName, messages) {
|
|
|
488
692
|
}
|
|
489
693
|
return value;
|
|
490
694
|
}
|
|
491
|
-
|
|
492
|
-
//#region lib/argv-guard.ts
|
|
695
|
+
// lib/argv-guard.ts
|
|
493
696
|
/**
|
|
494
697
|
* POSIX sh 单引号转义:' → '\''。把任意字符串安全放进单引号内,防注入。
|
|
495
698
|
*
|
|
@@ -503,22 +706,96 @@ function shq(value) {
|
|
|
503
706
|
return `'${value.replaceAll("'", String.raw`'\''`)}'`;
|
|
504
707
|
}
|
|
505
708
|
/**
|
|
506
|
-
*
|
|
709
|
+
* 绝对路径校验:非空、trim 后是绝对路径、无 NUL 字节;交出前按路径形态归一成唯一形态。
|
|
507
710
|
*
|
|
508
711
|
* 先 trim 再判:调用点会先 trim 再传进来,而直接调用 `args.repo` 的路径若带首尾空白,
|
|
509
712
|
* 会被「必须是绝对路径」这条误导性文案拦下——读起来像是路径写错了格式,实际只是多了
|
|
510
713
|
* 几个空格。顺带也挡住全空白(trim 后为空 ⇒ 触发 repoRequired)。
|
|
714
|
+
*
|
|
715
|
+
* 归一(posix.normalize + 去尾随斜杠)的目的:同一个仓库只允许一种字符串形态到达下游。
|
|
716
|
+
* `/repo//sub`、`/repo/./a`、`/repo/` 与 `/repo/sub` 是同一目录的几种写法,而会话目录
|
|
717
|
+
* 等按路径派生的键会把这些写法各存一份;`..` 段按词法折叠成实际目标(`/repo/../..`
|
|
718
|
+
* 落到 `/`)——折叠是 normalize 对绝对路径的既有行为,不存在「解析后仍含 `..`」的
|
|
719
|
+
* 形态,所以这里没有可写的拒绝分支。
|
|
511
720
|
*/
|
|
512
721
|
function assertAbsoluteRoot(root, messages) {
|
|
513
722
|
const trimmed = typeof root === "string" ? root.trim() : "";
|
|
514
723
|
if (trimmed.length === 0) throw new Error(messages.repoRequired);
|
|
515
724
|
if (trimmed.includes("\0")) throw new Error(format(messages.noNulBytes, { name: "repo" }));
|
|
516
|
-
if (
|
|
517
|
-
|
|
725
|
+
if (trimmed.startsWith("/")) {
|
|
726
|
+
const normalized = path.posix.normalize(trimmed);
|
|
727
|
+
return normalized.length > 1 ? normalized.replace(/\/+$/u, "") : normalized;
|
|
728
|
+
}
|
|
729
|
+
if (process.platform === "win32" && path.win32.isAbsolute(trimmed)) {
|
|
730
|
+
const normalized = path.win32.normalize(trimmed);
|
|
731
|
+
return normalized.length > 3 ? normalized.replace(/[\\/]+$/u, "") : normalized;
|
|
732
|
+
}
|
|
733
|
+
throw new Error(format(messages.repoMustBeAbsolute, { root: trimmed }));
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* `--rule` / `--background-file` 的路径闸:只允许 repo 根内的普通文件。
|
|
737
|
+
*
|
|
738
|
+
* 这两个 flag 会把文件内容读进 OCR 并可能送给 LLM;OCR 子进程以
|
|
739
|
+
* `danger-full-access` 运行,若放任 `../../.ssh/id_rsa` 之类的路径,一次工具调用就能
|
|
740
|
+
* 把仓库外的文件送进模型。realpath 先解开符号链接再比较,`path.resolve` 的文本前缀
|
|
741
|
+
* 判据挡不住指向仓库外的软链。
|
|
742
|
+
*
|
|
743
|
+
* @param root - 已校验为绝对路径的 repo 根
|
|
744
|
+
* @param value - 模型给的路径(相对 repo 或绝对)
|
|
745
|
+
* @param name - 参数名(报错点名)
|
|
746
|
+
* @param maxBytes - 允许的字节上限
|
|
747
|
+
* @param messages - 双语文案
|
|
748
|
+
* @returns 归一后的绝对路径
|
|
749
|
+
*/
|
|
750
|
+
function resolveRepoFile(root, value, name, maxBytes, messages) {
|
|
751
|
+
if (typeof value !== "string") throw new TypeError(format(messages.mustBeString, {
|
|
752
|
+
name,
|
|
753
|
+
received: typeof value
|
|
754
|
+
}));
|
|
755
|
+
const text = value.trim();
|
|
756
|
+
if (text === "") throw new Error(format(messages.repoFileMissing, {
|
|
757
|
+
name,
|
|
758
|
+
path: ""
|
|
759
|
+
}));
|
|
760
|
+
if (text.includes("\0")) throw new Error(format(messages.noNulBytes, { name }));
|
|
761
|
+
let rootReal;
|
|
762
|
+
try {
|
|
763
|
+
rootReal = realpathSync(root);
|
|
764
|
+
} catch (error) {
|
|
765
|
+
throw new Error(format(messages.repoRootUnreadable, { path: root }), { cause: error });
|
|
766
|
+
}
|
|
767
|
+
let real;
|
|
768
|
+
try {
|
|
769
|
+
real = realpathSync(path.resolve(rootReal, text));
|
|
770
|
+
} catch (error) {
|
|
771
|
+
throw new Error(format(messages.repoFileMissing, {
|
|
772
|
+
name,
|
|
773
|
+
path: text
|
|
774
|
+
}), { cause: error });
|
|
775
|
+
}
|
|
776
|
+
const rel = path.relative(rootReal, real);
|
|
777
|
+
if (path.isAbsolute(rel) || rel === ".." || rel.startsWith(`..${path.sep}`)) throw new Error(format(messages.repoFileOutside, {
|
|
778
|
+
name,
|
|
779
|
+
path: text
|
|
780
|
+
}));
|
|
781
|
+
const stat = statSync(real);
|
|
782
|
+
if (!stat.isFile()) throw new Error(format(messages.repoFileNotFile, {
|
|
783
|
+
name,
|
|
784
|
+
path: text
|
|
785
|
+
}));
|
|
786
|
+
const { size } = stat;
|
|
787
|
+
if (size > maxBytes) throw new Error(format(messages.repoFileTooLarge, {
|
|
788
|
+
name,
|
|
789
|
+
path: text,
|
|
790
|
+
limit: String(maxBytes)
|
|
791
|
+
}));
|
|
792
|
+
return real;
|
|
518
793
|
}
|
|
519
794
|
/** limit 非法(NaN / ≤0 / 非整数)时的保守兜底上限:宁可比调用方的意图更严。 */
|
|
520
795
|
const DEFAULT_LIST_LIMIT = 200;
|
|
796
|
+
/** `auto` = 不传 `--effort`,让 OCR 自己的配置决定;工具 schema 仍只暴露三档。 */
|
|
521
797
|
const EFFORTS = [
|
|
798
|
+
"auto",
|
|
522
799
|
"low",
|
|
523
800
|
"medium",
|
|
524
801
|
"high"
|
|
@@ -527,11 +804,28 @@ const EFFORTS = [
|
|
|
527
804
|
function clampEffort(value, fallback) {
|
|
528
805
|
return EFFORTS.find((level) => level === value) ?? fallback;
|
|
529
806
|
}
|
|
807
|
+
/** 字符串里第一个落在 glob 花括号内的逗号位置(没有则 -1)。 */
|
|
808
|
+
function firstCommaInsideBraces(text) {
|
|
809
|
+
let depth = 0;
|
|
810
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
811
|
+
const ch = text[index];
|
|
812
|
+
if (ch === "{") depth += 1;
|
|
813
|
+
else if (ch === "}" && depth > 0) depth -= 1;
|
|
814
|
+
else if (ch === "," && depth > 0) return index;
|
|
815
|
+
}
|
|
816
|
+
return -1;
|
|
817
|
+
}
|
|
530
818
|
/** 入参 → 原始项序列(commaList 的第一段:只判「值从哪来」,不判内容)。
|
|
531
819
|
* 数组分支保留逐元素 for-of 而不是 `value.map`:map 会跳过稀疏数组的空洞,
|
|
532
820
|
* 空洞本是「非字符串元素」这一条该拦的形态,跳过就等于静默少一项过滤条件。 */
|
|
533
821
|
function listSourceItems(value, name, messages) {
|
|
534
|
-
if (typeof value === "string")
|
|
822
|
+
if (typeof value === "string") {
|
|
823
|
+
if (firstCommaInsideBraces(value) >= 0) throw new TypeError(format(messages.commaInStringBraces, {
|
|
824
|
+
name,
|
|
825
|
+
item: value
|
|
826
|
+
}));
|
|
827
|
+
return value.split(",");
|
|
828
|
+
}
|
|
535
829
|
if (Array.isArray(value)) {
|
|
536
830
|
const items = [];
|
|
537
831
|
for (const item of value) {
|
|
@@ -550,15 +844,22 @@ function listSourceItems(value, name, messages) {
|
|
|
550
844
|
function takeTrimmedItems(items, name, limit, messages) {
|
|
551
845
|
const cap = Number.isSafeInteger(limit) && limit > 0 ? limit : DEFAULT_LIST_LIMIT;
|
|
552
846
|
const out = [];
|
|
847
|
+
let dropped = 0;
|
|
553
848
|
for (const item of items) {
|
|
554
849
|
if (item.includes("\0")) throw new Error(format(messages.noNulBytes, { name }));
|
|
555
850
|
const trimmed = item.trim();
|
|
556
|
-
if (trimmed.length > 0
|
|
557
|
-
if (
|
|
851
|
+
if (trimmed.length > 0 && out.length >= cap) dropped += 1;
|
|
852
|
+
else if (trimmed.length > 0) out.push(trimmed);
|
|
558
853
|
}
|
|
854
|
+
if (dropped > 0) throw new Error(format(messages.listOverLimit, {
|
|
855
|
+
name,
|
|
856
|
+
dropped: String(dropped),
|
|
857
|
+
limit: String(cap)
|
|
858
|
+
}));
|
|
559
859
|
return out;
|
|
560
860
|
}
|
|
561
|
-
/**
|
|
861
|
+
/** 入参 → 规范化条目数组(--exclude/--path 契约)。返回值是**条目数组**;把数组按逗号
|
|
862
|
+
* 拼接成 flag 值由调用点负责(lib/cli.ts 的 `shq(excludes.join(","))`)。
|
|
562
863
|
* 元素类型严格:`value.map(String)` 会把对象变成 `[object Object]` 静默混入
|
|
563
864
|
* 模式串(过滤条件失效 = 审查范围被扩大),故非字符串元素一律抛错。
|
|
564
865
|
* 防注入由调用点 shq() 兜底。 */
|
|
@@ -585,10 +886,18 @@ const REVIEW_OUTPUT_FORMATS = [
|
|
|
585
886
|
function resolveOutputFormat(value) {
|
|
586
887
|
return REVIEW_OUTPUT_FORMATS.find((candidate) => candidate === value) ?? "json";
|
|
587
888
|
}
|
|
588
|
-
|
|
589
|
-
//#region lib/cli.ts
|
|
889
|
+
// lib/cli.ts
|
|
590
890
|
/** `ocr review|scan|delegate preview` 的背景说明 flag(三处命令构造共用一条)。
|
|
591
891
|
* pushTextFlag 按 `flag.slice(2)` 反推报错用的参数名,故值必须带 `--` 前缀。 */
|
|
892
|
+
/** `--exclude` 的模式条数上限。**不是审查范围的上限**,只是防一条失控输入把命令串撑爆;
|
|
893
|
+
* 真实使用里几十条就到头,给到 2000 是为了"尽量不截断"——超了会明确告警,不静默丢。 */
|
|
894
|
+
const EXCLUDE_LIST_LIMIT = 2e3;
|
|
895
|
+
/** `--path` 的路径条数上限(同 EXCLUDE_LIST_LIMIT 的取向:够大 + 超限告警)。 */
|
|
896
|
+
const PATH_LIST_LIMIT = 2e3;
|
|
897
|
+
/** `--rule` / `--background-file` 的文件大小上限:规则是检查清单,背景是需求文档。 */
|
|
898
|
+
const RULE_FILE_MAX_BYTES = 262144;
|
|
899
|
+
const BACKGROUND_FILE_MAX_BYTES = 1048576;
|
|
900
|
+
const MAX_GIT_PROCS_FLAG = "--max-git-procs";
|
|
592
901
|
const BACKGROUND_FLAG = "--background";
|
|
593
902
|
/**
|
|
594
903
|
* 本包的契约是按外部 `ocr` CLI 的哪一版核对的(README 的前置条件、装法建议与工具回显里
|
|
@@ -599,19 +908,27 @@ const BACKGROUND_FLAG = "--background";
|
|
|
599
908
|
* test/readme-parity.test.ts 再断言两侧 README 写的也是它。三处只有一处是人写的、
|
|
600
909
|
* 靠门禁钉住另两处,就不会出现「README 教用户装 A、代码按 B 校验」这种两边各自自洽的错。
|
|
601
910
|
*/
|
|
602
|
-
const EXPECTED_OCR_CLI_VERSION = "1.13.
|
|
603
|
-
/** root 解析:显式绝对路径优先;缺失/空串回退到当前会话工作区。 */
|
|
604
|
-
function resolveRoot(value, fallback, messages) {
|
|
605
|
-
if (typeof value === "string" && value.trim().length > 0) return assertAbsoluteRoot(value.trim(), messages);
|
|
606
|
-
if (typeof fallback === "string" && fallback.trim().length > 0) return assertAbsoluteRoot(fallback.trim(), messages);
|
|
607
|
-
throw new Error(messages.repoRequiredUnknownWorkspace);
|
|
608
|
-
}
|
|
911
|
+
const EXPECTED_OCR_CLI_VERSION = "1.13.3";
|
|
609
912
|
/** 报错用的值形态(永不调用可能被劫持的 toJSON/toString)。host.ts 的入参闸
|
|
610
913
|
* (effort/wait)复用同一口径,故导出。 */
|
|
611
914
|
function describeValue(value) {
|
|
612
915
|
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
|
|
613
916
|
return Array.isArray(value) ? "array" : typeof value;
|
|
614
917
|
}
|
|
918
|
+
/** root 解析:显式绝对路径优先;缺失/空串回退到当前会话工作区。
|
|
919
|
+
*
|
|
920
|
+
* 错类型必须抛错,不能落进 fallback:宿主对工具入参零校验(见 `idValue` 的说明),
|
|
921
|
+
* `{repo: 123}` 静默回退成当前会话工作区,等于「调用方明确指定了仓库」被换成另一个
|
|
922
|
+
* 仓库去审,而回执里不会留下任何痕迹。空串/缺省才是「没指定」。 */
|
|
923
|
+
function resolveRoot(value, fallback, messages) {
|
|
924
|
+
if (value !== void 0 && value !== null && typeof value !== "string") throw new TypeError(format(messages.mustBeString, {
|
|
925
|
+
name: "repo",
|
|
926
|
+
received: describeValue(value)
|
|
927
|
+
}));
|
|
928
|
+
if (typeof value === "string" && value.trim().length > 0) return assertAbsoluteRoot(value.trim(), messages);
|
|
929
|
+
if (typeof fallback === "string" && fallback.trim().length > 0) return assertAbsoluteRoot(fallback.trim(), messages);
|
|
930
|
+
throw new Error(messages.repoRequiredUnknownWorkspace);
|
|
931
|
+
}
|
|
615
932
|
/** 字符串标量参数:键存在即必须是字符串。
|
|
616
933
|
*
|
|
617
934
|
* 为什么拒绝而不是忽略:宿主对工具入参零校验(dsh tools index.ts
|
|
@@ -629,41 +946,108 @@ function idValue(value, name, messages) {
|
|
|
629
946
|
if (trimmed.includes("\0")) throw new Error(format(messages.noNulBytes, { name }));
|
|
630
947
|
return trimmed;
|
|
631
948
|
}
|
|
949
|
+
/** 纯十进制整数的文本形态:数值槽位接受的唯一字符串写法。 */
|
|
950
|
+
const DECIMAL_INTEGER_TEXT = /^\d+$/u;
|
|
951
|
+
/**
|
|
952
|
+
* 数值槽位的**唯一**取值口:只接受 `number` 与纯十进制数字串,其余一律 null。
|
|
953
|
+
*
|
|
954
|
+
* 为什么单列一份:`Number()` 会把 true 当 1、把 [] 当 0、把 "0x10" 当 16、把 "1e2" 当 100,
|
|
955
|
+
* 于是错类型或怪写法的输入会被静默读成**另一个数**。数值 flag 与 session limit 两处槽位
|
|
956
|
+
* 共用这一条判据,包里就不会存在第二种数值口径(此前 session limit 用 `Number()`,
|
|
957
|
+
* `{limit: "0x10"}` 被读成 16)。
|
|
958
|
+
* 非安全整数(1e300、1e21)与非法形态同样 null,由调用点按各自的范围判据拒绝。
|
|
959
|
+
*/
|
|
960
|
+
function decimalInteger(value) {
|
|
961
|
+
const isDecimalText = typeof value === "string" && DECIMAL_INTEGER_TEXT.test(value.trim());
|
|
962
|
+
if (typeof value !== "number" && !isDecimalText) return null;
|
|
963
|
+
const num = Number(value);
|
|
964
|
+
return Number.isSafeInteger(num) ? num : null;
|
|
965
|
+
}
|
|
632
966
|
/**
|
|
633
967
|
* 数值 flag 忠实透传(桥接层不设钳制——OCR 原生语义:0 = 配置/模板默认或不限,
|
|
634
968
|
* 负数/非整数由 OCR 侧报错,但桥接层先做同源校验给出更早的错误)。
|
|
635
969
|
* value 为 undefined/null 时省略 flag(跟随 OCR flag 默认值)。
|
|
636
970
|
*
|
|
637
971
|
* 注入面:数值经 `String(num)` 落进命令串(不走 shq)。数值 token 结构上不可能带
|
|
638
|
-
* shell
|
|
972
|
+
* shell 元字符;安全整数经 String() 只产出 `-?[0-9]+`,而 min ≥ 0 挡掉负号。
|
|
639
973
|
*/
|
|
640
974
|
function pushIntFlag(parts, flag, value, min, messages) {
|
|
641
975
|
if (value === void 0 || value === null) return;
|
|
642
|
-
const
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
if (
|
|
654
|
-
|
|
976
|
+
const num = decimalInteger(value);
|
|
977
|
+
if (num === null || num < min) throw new Error(format(messages.intFlagMustBeInt, {
|
|
978
|
+
flag,
|
|
979
|
+
min: String(min),
|
|
980
|
+
received: describeValue(value)
|
|
981
|
+
}));
|
|
982
|
+
parts.push(flag, String(num));
|
|
983
|
+
}
|
|
984
|
+
/** 布尔 flag:undefined/null 视为 false;其余非布尔值抛错(字符串 "false" 不能当真值)。 */
|
|
985
|
+
function boolFlag(value, name, messages) {
|
|
986
|
+
if (value === void 0 || value === null) return false;
|
|
987
|
+
if (typeof value !== "boolean") throw new TypeError(format(messages.boolMustBeBoolean, {
|
|
988
|
+
name,
|
|
989
|
+
received: describeValue(value)
|
|
990
|
+
}));
|
|
991
|
+
return value;
|
|
992
|
+
}
|
|
993
|
+
function pushBoolFlag(parts, flag, value, name, messages) {
|
|
994
|
+
if (boolFlag(value, name, messages)) parts.push(flag);
|
|
995
|
+
}
|
|
996
|
+
/** `--rule` / `--background-file`:缺省/空串视为未给;其余交给路径闸(repo 根内普通文件)。 */
|
|
997
|
+
function repoFileOf(value, repo, name, maxBytes, messages) {
|
|
998
|
+
if (value === void 0 || value === null) return null;
|
|
999
|
+
if (typeof value === "string" && value.trim() === "") return null;
|
|
1000
|
+
return resolveRepoFile(repo, value, name, maxBytes, messages);
|
|
1001
|
+
}
|
|
1002
|
+
/** 预览与正常审查共用的选择面:exclude、rule、max-git-procs。 */
|
|
1003
|
+
function selectionFlags(args, repo, messages) {
|
|
1004
|
+
const parts = [];
|
|
1005
|
+
const excludes = commaList(args.exclude, "exclude", EXCLUDE_LIST_LIMIT, messages);
|
|
1006
|
+
if (excludes.length > 0) parts.push("--exclude", shq(excludes.join(",")));
|
|
1007
|
+
const rule = repoFileOf(args.rule, repo, "rule", RULE_FILE_MAX_BYTES, messages);
|
|
1008
|
+
if (rule !== null) parts.push("--rule", shq(rule));
|
|
1009
|
+
pushIntFlag(parts, MAX_GIT_PROCS_FLAG, args.maxGitProcs, 1, messages);
|
|
1010
|
+
return parts;
|
|
655
1011
|
}
|
|
656
1012
|
/** 模式参数解析:workspace(默认)/commit/branch 互斥,非法组合与错类型抛错。
|
|
657
1013
|
*
|
|
658
1014
|
* 互斥判定必须在两个模式分支之前一次做完:若先判 commit(`commit 非空` 时条件
|
|
659
1015
|
* 恒真),后面的「branch 与 commit 互斥」就永不可达,`{scope:branch, commit:x}`
|
|
660
1016
|
* 会被静默降级成 commit 审查——审错范围却按结论上报。 */
|
|
661
|
-
|
|
1017
|
+
const KNOWN_SCOPES = /* @__PURE__ */ new Set([
|
|
1018
|
+
"workspace",
|
|
1019
|
+
"commit",
|
|
1020
|
+
"branch"
|
|
1021
|
+
]);
|
|
1022
|
+
/** 与 `scope` 冲突时报告的是哪一种范围参数(两者都没给时返回空串)。 */
|
|
1023
|
+
function otherScopeOf(commit, from, to) {
|
|
1024
|
+
if (commit !== "") return "commit";
|
|
1025
|
+
return from === "" && to === "" ? "" : "from/to";
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* `scope` 是**声明**,不是从别的参数反推出来的结论:先按 from/to/commit 推模式再看
|
|
1029
|
+
* scope,`{scope:"nope", from:"a", to:"b"}` 会被当成 branch 静默放行,而调用方声明的
|
|
1030
|
+
* 范围与真正审的范围不是一回事。
|
|
1031
|
+
*/
|
|
1032
|
+
function declaredScopeOf(args, messages) {
|
|
662
1033
|
const scopeName = idValue(args.scope, "scope", messages).toLowerCase();
|
|
663
|
-
|
|
1034
|
+
if (scopeName === "") return "workspace";
|
|
1035
|
+
if (!KNOWN_SCOPES.has(scopeName)) throw new Error(format(messages.unknownScope, { scope: scopeName }));
|
|
1036
|
+
return scopeName;
|
|
1037
|
+
}
|
|
1038
|
+
function scopeFlags(args, messages) {
|
|
1039
|
+
const scope = declaredScopeOf(args, messages);
|
|
1040
|
+
const explicit = idValue(args.scope, "scope", messages) !== "";
|
|
664
1041
|
const from = idValue(args.from, "from", messages);
|
|
665
1042
|
const to = idValue(args.to, "to", messages);
|
|
666
1043
|
const commit = idValue(args.commit, "commit", messages);
|
|
1044
|
+
if (explicit && scope === "workspace") {
|
|
1045
|
+
const given = otherScopeOf(commit, from, to);
|
|
1046
|
+
if (given !== "") throw new Error(format(messages.scopeMismatch, {
|
|
1047
|
+
scope,
|
|
1048
|
+
given
|
|
1049
|
+
}));
|
|
1050
|
+
}
|
|
667
1051
|
const wantsBranch = scope === "branch" || from !== "" || to !== "";
|
|
668
1052
|
const wantsCommit = scope === "commit" || commit !== "";
|
|
669
1053
|
if (wantsBranch && wantsCommit) throw new Error(messages.scopeBranchCommitExclusive);
|
|
@@ -680,7 +1064,6 @@ function scopeFlags(args, messages) {
|
|
|
680
1064
|
if (scope === "commit" && commit === "") throw new Error(messages.commitNeedsValue);
|
|
681
1065
|
return ["--commit", shq(commit)];
|
|
682
1066
|
}
|
|
683
|
-
if (scope !== "workspace") throw new Error(format(messages.unknownScope, { scope }));
|
|
684
1067
|
return [];
|
|
685
1068
|
}
|
|
686
1069
|
/** 可选文本 flag:值缺省/空串则省略,错类型抛错(静默丢弃等于悄悄改变语义)。 */
|
|
@@ -695,7 +1078,7 @@ function pushTextFlag(parts, flag, value, messages) {
|
|
|
695
1078
|
* buildReviewCommand 在拿到 scopeFlags 结果后前置校验。 */
|
|
696
1079
|
function optionalFlags(args, messages) {
|
|
697
1080
|
const parts = [];
|
|
698
|
-
const excludes = commaList(args.exclude, "exclude",
|
|
1081
|
+
const excludes = commaList(args.exclude, "exclude", EXCLUDE_LIST_LIMIT, messages);
|
|
699
1082
|
if (excludes.length > 0) parts.push("--exclude", shq(excludes.join(",")));
|
|
700
1083
|
pushTextFlag(parts, BACKGROUND_FLAG, args.background, messages);
|
|
701
1084
|
pushTextFlag(parts, "--provider", args.provider, messages);
|
|
@@ -944,6 +1327,18 @@ function parseOcrVersion(text) {
|
|
|
944
1327
|
return /open-code-review\s+v?(?<version>\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)/u.exec(text)?.groups?.["version"] ?? null;
|
|
945
1328
|
}
|
|
946
1329
|
/**
|
|
1330
|
+
* `--format` 交给 OCR 的取值:本包只认 json——`lib/parse.ts` 的解析层只解 JSON 一份形状。
|
|
1331
|
+
*
|
|
1332
|
+
* 白名单(`resolveOutputFormat`)管的是注入面,它放行的 text/sarif 在这里被拒:
|
|
1333
|
+
* 那两个值没有下游,交下去只会换回一条「OCR 输出解析失败」,把「参数不在本包支持面内」
|
|
1334
|
+
* 说成 OCR 的错。`format` 不在任何工具的 parameters 里,走到这里的只有 PTC/直连调用方。
|
|
1335
|
+
*/
|
|
1336
|
+
function jsonOnlyFormat(value, messages) {
|
|
1337
|
+
const outputFormat = resolveOutputFormat(value);
|
|
1338
|
+
if (outputFormat !== "json") throw new Error(format(messages.formatNotJson, { format: outputFormat }));
|
|
1339
|
+
return outputFormat;
|
|
1340
|
+
}
|
|
1341
|
+
/**
|
|
947
1342
|
* 构造 `ocr review` 命令。--output 固定为调用方给定的临时文件路径(防截断),
|
|
948
1343
|
* --audience agent --format json 是默认(结构化、无进度行)。
|
|
949
1344
|
* outputPath 缺省(后台模式)省略 --output:结果以 OCR 会话记录为准。
|
|
@@ -951,16 +1346,28 @@ function parseOcrVersion(text) {
|
|
|
951
1346
|
*/
|
|
952
1347
|
function buildReviewCommand(args, outputPath, messages) {
|
|
953
1348
|
const repo = assertAbsoluteRoot(args.repo, messages);
|
|
1349
|
+
const outputFormat = jsonOnlyFormat(args.format, messages);
|
|
1350
|
+
const preview = boolFlag(args.preview, "preview", messages);
|
|
954
1351
|
const parts = [
|
|
955
1352
|
ocrCommand(),
|
|
956
1353
|
"review",
|
|
957
1354
|
"--audience",
|
|
958
1355
|
"agent",
|
|
959
1356
|
"--format",
|
|
960
|
-
|
|
1357
|
+
outputFormat
|
|
961
1358
|
];
|
|
1359
|
+
const resume = idValue(args.resume, "resume", messages);
|
|
1360
|
+
if (preview) {
|
|
1361
|
+
if (resume !== "") throw new Error(messages.previewRejectsResume);
|
|
1362
|
+
parts.push("--preview", ...scopeFlags(args, messages), ...selectionFlags(args, repo, messages));
|
|
1363
|
+
if (outputPath !== void 0) parts.push("--output", shq(outputPath));
|
|
1364
|
+
return {
|
|
1365
|
+
command: parts.join(" "),
|
|
1366
|
+
workdir: repo
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
962
1369
|
const effort = clampEffort(args.effort, "medium");
|
|
963
|
-
parts.push("--effort", effort);
|
|
1370
|
+
if (effort !== "auto") parts.push("--effort", effort);
|
|
964
1371
|
pushIntFlag(parts, "--concurrency", args.concurrency, 1, messages);
|
|
965
1372
|
pushIntFlag(parts, "--timeout", args.timeoutMinutes, 0, messages);
|
|
966
1373
|
pushIntFlag(parts, "--max-tools", args.maxTools, 0, messages);
|
|
@@ -968,7 +1375,12 @@ function buildReviewCommand(args, outputPath, messages) {
|
|
|
968
1375
|
pushIntFlag(parts, "--max-tokens-budget", args.maxTokensBudget, 0, messages);
|
|
969
1376
|
const scopeParts = scopeFlags(args, messages);
|
|
970
1377
|
parts.push(...scopeParts, ...optionalFlags(args, messages));
|
|
971
|
-
|
|
1378
|
+
pushBoolFlag(parts, "--no-filter", args.noFilter, "noFilter", messages);
|
|
1379
|
+
const rule = repoFileOf(args.rule, repo, "rule", RULE_FILE_MAX_BYTES, messages);
|
|
1380
|
+
if (rule !== null) parts.push("--rule", shq(rule));
|
|
1381
|
+
const backgroundFile = repoFileOf(args.backgroundFile, repo, "backgroundFile", BACKGROUND_FILE_MAX_BYTES, messages);
|
|
1382
|
+
if (backgroundFile !== null) parts.push("--background-file", shq(backgroundFile));
|
|
1383
|
+
pushIntFlag(parts, MAX_GIT_PROCS_FLAG, args.maxGitProcs, 1, messages);
|
|
972
1384
|
if (resume !== "") {
|
|
973
1385
|
if (scopeParts.length === 0) throw new Error(messages.resumeNeedsScope);
|
|
974
1386
|
parts.push("--resume", shq(resume));
|
|
@@ -985,27 +1397,37 @@ function buildReviewCommand(args, outputPath, messages) {
|
|
|
985
1397
|
* outputPath 缺省(后台模式)省略 --output:结果以 OCR 会话记录为准。 */
|
|
986
1398
|
function buildScanCommand(args, outputPath, messages) {
|
|
987
1399
|
const repo = assertAbsoluteRoot(args.repo, messages);
|
|
1400
|
+
const outputFormat = jsonOnlyFormat(args.format, messages);
|
|
1401
|
+
const preview = boolFlag(args.preview, "preview", messages);
|
|
1402
|
+
const resume = idValue(args.resume, "resume", messages);
|
|
1403
|
+
if (preview && resume !== "") throw new Error(messages.previewRejectsResume);
|
|
988
1404
|
const parts = [
|
|
989
1405
|
ocrCommand(),
|
|
990
1406
|
"scan",
|
|
991
1407
|
"--audience",
|
|
992
1408
|
"agent",
|
|
993
1409
|
"--format",
|
|
994
|
-
|
|
1410
|
+
outputFormat
|
|
995
1411
|
];
|
|
996
|
-
|
|
1412
|
+
if (preview) parts.push("--preview");
|
|
1413
|
+
const paths = commaList(args.path, "path", PATH_LIST_LIMIT, messages);
|
|
997
1414
|
if (paths.length > 0) parts.push("--path", shq(paths.join(",")));
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1415
|
+
if (!preview) {
|
|
1416
|
+
pushTextFlag(parts, "--batch", args.batch, messages);
|
|
1417
|
+
pushTextFlag(parts, "--provider", args.provider, messages);
|
|
1418
|
+
pushTextFlag(parts, "--model", args.model, messages);
|
|
1419
|
+
pushIntFlag(parts, "--concurrency", args.concurrency, 1, messages);
|
|
1420
|
+
pushIntFlag(parts, "--timeout", args.timeoutMinutes, 0, messages);
|
|
1421
|
+
pushIntFlag(parts, "--max-tools", args.maxTools, 0, messages);
|
|
1422
|
+
pushIntFlag(parts, "--max-tokens", args.maxTokens, 0, messages);
|
|
1423
|
+
pushIntFlag(parts, "--max-tokens-budget", args.maxTokensBudget, 0, messages);
|
|
1424
|
+
pushBoolFlag(parts, "--no-plan", args.noPlan, "noPlan", messages);
|
|
1425
|
+
pushBoolFlag(parts, "--no-dedup", args.noDedup, "noDedup", messages);
|
|
1426
|
+
pushBoolFlag(parts, "--no-summary", args.noSummary, "noSummary", messages);
|
|
1427
|
+
if (resume !== "") parts.push("--resume", shq(resume));
|
|
1428
|
+
}
|
|
1429
|
+
parts.push(...selectionFlags(args, repo, messages));
|
|
1430
|
+
if (!preview) pushTextFlag(parts, BACKGROUND_FLAG, args.background, messages);
|
|
1009
1431
|
if (outputPath !== void 0) parts.push("--output", shq(outputPath));
|
|
1010
1432
|
return {
|
|
1011
1433
|
command: parts.join(" "),
|
|
@@ -1027,9 +1449,14 @@ function buildDelegatePreviewCommand(args, messages) {
|
|
|
1027
1449
|
"json",
|
|
1028
1450
|
...scopeFlags(scopeArgs, messages)
|
|
1029
1451
|
];
|
|
1030
|
-
const excludes = commaList(args.exclude, "exclude",
|
|
1452
|
+
const excludes = commaList(args.exclude, "exclude", EXCLUDE_LIST_LIMIT, messages);
|
|
1031
1453
|
if (excludes.length > 0) parts.push("--exclude", shq(excludes.join(",")));
|
|
1032
1454
|
pushTextFlag(parts, BACKGROUND_FLAG, args.background, messages);
|
|
1455
|
+
const backgroundFile = repoFileOf(args.backgroundFile, repo, "backgroundFile", BACKGROUND_FILE_MAX_BYTES, messages);
|
|
1456
|
+
if (backgroundFile !== null) parts.push("--background-file", shq(backgroundFile));
|
|
1457
|
+
const rule = repoFileOf(args.rule, repo, "rule", RULE_FILE_MAX_BYTES, messages);
|
|
1458
|
+
if (rule !== null) parts.push("--rule", shq(rule));
|
|
1459
|
+
pushIntFlag(parts, MAX_GIT_PROCS_FLAG, args.maxGitProcs, 1, messages);
|
|
1033
1460
|
return {
|
|
1034
1461
|
command: parts.join(" "),
|
|
1035
1462
|
workdir: repo
|
|
@@ -1040,66 +1467,213 @@ function buildDelegateRuleCommand(args, messages) {
|
|
|
1040
1467
|
const repo = assertAbsoluteRoot(args.repo, messages);
|
|
1041
1468
|
const paths = commaList(args.paths, "paths", 200, messages).map((filePath) => shq(filePath));
|
|
1042
1469
|
if (paths.length === 0) throw new Error(messages.pathsRequired);
|
|
1470
|
+
const parts = [
|
|
1471
|
+
ocrCommand(),
|
|
1472
|
+
"delegate",
|
|
1473
|
+
"rule",
|
|
1474
|
+
"--format",
|
|
1475
|
+
"json"
|
|
1476
|
+
];
|
|
1477
|
+
const rule = repoFileOf(args.rule, repo, "rule", RULE_FILE_MAX_BYTES, messages);
|
|
1478
|
+
if (rule !== null) parts.push("--rule", shq(rule));
|
|
1479
|
+
parts.push("--", ...paths);
|
|
1480
|
+
return {
|
|
1481
|
+
command: parts.join(" "),
|
|
1482
|
+
workdir: repo
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
/** `ocr rules check <file>`:只做规则解析,不跑 LLM;`path` 是待查询的文件路径。 */
|
|
1486
|
+
function buildRulesCheckCommand(args, messages) {
|
|
1487
|
+
const repo = assertAbsoluteRoot(args.repo, messages);
|
|
1488
|
+
const filePath = idValue(args.path, "path", messages);
|
|
1489
|
+
if (filePath === "") throw new Error(messages.rulesPathRequired);
|
|
1490
|
+
const parts = [
|
|
1491
|
+
ocrCommand(),
|
|
1492
|
+
"rules",
|
|
1493
|
+
"check",
|
|
1494
|
+
"--repo",
|
|
1495
|
+
shq(repo)
|
|
1496
|
+
];
|
|
1497
|
+
const rule = repoFileOf(args.rule, repo, "rule", RULE_FILE_MAX_BYTES, messages);
|
|
1498
|
+
if (rule !== null) parts.push("--rule", shq(rule));
|
|
1499
|
+
parts.push("--", shq(filePath));
|
|
1043
1500
|
return {
|
|
1044
|
-
command:
|
|
1045
|
-
ocrCommand(),
|
|
1046
|
-
"delegate",
|
|
1047
|
-
"rule",
|
|
1048
|
-
"--format",
|
|
1049
|
-
"json",
|
|
1050
|
-
"--",
|
|
1051
|
-
...paths
|
|
1052
|
-
].join(" "),
|
|
1501
|
+
command: parts.join(" "),
|
|
1053
1502
|
workdir: repo
|
|
1054
1503
|
};
|
|
1055
1504
|
}
|
|
1056
|
-
/**
|
|
1505
|
+
/** `ocr llm providers|models`:只读诊断命令,不需要仓库目录。 */
|
|
1506
|
+
function buildLlmCommand(action) {
|
|
1507
|
+
const sub = action === "providers" ? "providers" : "models";
|
|
1508
|
+
return `${ocrCommand()} llm ${sub} --color never`;
|
|
1509
|
+
}
|
|
1510
|
+
/** `ocr auth status`:卡片只读查询 ChatGPT 登录态。 */
|
|
1511
|
+
function buildAuthStatusCommand() {
|
|
1512
|
+
return `${ocrCommand()} auth status --provider chatgpt --color never`;
|
|
1513
|
+
}
|
|
1514
|
+
/** 登录动作保持人工执行(OAuth 要浏览器 + 本地回调),卡片只显示这条命令。 */
|
|
1515
|
+
const CHATGPT_LOGIN_COMMAND = "ocr auth login --provider chatgpt";
|
|
1516
|
+
/** session 子命令白名单(子命令名裸拼进命令串,只可能是这些字面量)。 */
|
|
1057
1517
|
const SESSION_ACTIONS = [
|
|
1058
1518
|
"list",
|
|
1059
1519
|
"show",
|
|
1060
|
-
"comments"
|
|
1520
|
+
"comments",
|
|
1521
|
+
"compare",
|
|
1522
|
+
"export"
|
|
1523
|
+
];
|
|
1524
|
+
const SESSION_SEVERITIES = [
|
|
1525
|
+
"critical",
|
|
1526
|
+
"high",
|
|
1527
|
+
"medium",
|
|
1528
|
+
"low"
|
|
1061
1529
|
];
|
|
1530
|
+
/** severity 过滤:大小写归一 + 白名单;未知值报错而不是交给 CLI 返回空集。 */
|
|
1531
|
+
function severityFilter(value, messages) {
|
|
1532
|
+
return commaList(value, "severity", SESSION_SEVERITIES.length, messages).map((item) => {
|
|
1533
|
+
const lower = item.toLowerCase();
|
|
1534
|
+
if (!SESSION_SEVERITIES.includes(lower)) throw new Error(format(messages.invalidSeverity, { value: item }));
|
|
1535
|
+
return lower;
|
|
1536
|
+
});
|
|
1537
|
+
}
|
|
1062
1538
|
/** session list --limit:缺省 10(OCR 原生默认 20,本包收紧;OCR 的 0=不限在本包
|
|
1063
1539
|
* 上限 100 下无意义)。显式给出时必须是 1-100 的整数(与工具 description 的承诺
|
|
1064
1540
|
* 一致),越界/错类型抛错而不是钳制——静默钳到 1 会把「想看更多」的 0 变成
|
|
1065
1541
|
* 「只看一条」。数字字符串接受("3"),与 pushIntFlag 的数值槽位同一口径。 */
|
|
1066
1542
|
function sessionLimit(value, messages) {
|
|
1067
1543
|
if (value === void 0 || value === null) return 10;
|
|
1068
|
-
|
|
1069
|
-
if (
|
|
1070
|
-
else if (typeof value === "string") num = Number(value);
|
|
1071
|
-
if (!Number.isInteger(num) || num < 1 || num > 100) throw new Error(format(messages.sessionLimitRange, { received: describeValue(value) }));
|
|
1544
|
+
const num = decimalInteger(value);
|
|
1545
|
+
if (num === null || num < 1 || num > 100) throw new Error(format(messages.sessionLimitRange, { received: describeValue(value) }));
|
|
1072
1546
|
return num;
|
|
1073
1547
|
}
|
|
1074
|
-
|
|
1548
|
+
function sessionListParts(ctx) {
|
|
1549
|
+
if (ctx.id !== "") throw new Error(ctx.messages.listRejectsId);
|
|
1550
|
+
if (ctx.before !== "" || ctx.after !== "") throw new Error(ctx.messages.beforeAfterOnlyForCompare);
|
|
1551
|
+
if (ctx.hasFilters) throw new Error(ctx.messages.filtersOnlyForComments);
|
|
1552
|
+
return [
|
|
1553
|
+
ocrCommand(),
|
|
1554
|
+
"session",
|
|
1555
|
+
"list",
|
|
1556
|
+
"--json",
|
|
1557
|
+
"--repo",
|
|
1558
|
+
shq(ctx.repo),
|
|
1559
|
+
"--limit",
|
|
1560
|
+
String(sessionLimit(ctx.limit, ctx.messages))
|
|
1561
|
+
];
|
|
1562
|
+
}
|
|
1563
|
+
function sessionCompareParts(ctx) {
|
|
1564
|
+
if (ctx.before === "" || ctx.after === "") throw new Error(ctx.messages.compareNeedsBoth);
|
|
1565
|
+
if (ctx.id !== "" || ctx.limit !== void 0 && ctx.limit !== null || ctx.hasFilters) throw new Error(ctx.messages.compareRejectsArgs);
|
|
1566
|
+
return [
|
|
1567
|
+
ocrCommand(),
|
|
1568
|
+
"session",
|
|
1569
|
+
"compare",
|
|
1570
|
+
"--json",
|
|
1571
|
+
"--repo",
|
|
1572
|
+
shq(ctx.repo),
|
|
1573
|
+
"--",
|
|
1574
|
+
shq(ctx.before),
|
|
1575
|
+
shq(ctx.after)
|
|
1576
|
+
];
|
|
1577
|
+
}
|
|
1578
|
+
function sessionExportParts(ctx) {
|
|
1579
|
+
if (ctx.before !== "" || ctx.after !== "" || ctx.limit !== void 0 && ctx.limit !== null) throw new Error(ctx.messages.exportRejectsArgs);
|
|
1580
|
+
if (ctx.hasFilters) throw new Error(ctx.messages.filtersOnlyForComments);
|
|
1581
|
+
if (ctx.output === "") throw new Error(ctx.messages.exportOutputRequired);
|
|
1582
|
+
const parts = [
|
|
1583
|
+
ocrCommand(),
|
|
1584
|
+
"session",
|
|
1585
|
+
"export",
|
|
1586
|
+
"--repo",
|
|
1587
|
+
shq(ctx.repo),
|
|
1588
|
+
"-o",
|
|
1589
|
+
shq(ctx.output)
|
|
1590
|
+
];
|
|
1591
|
+
if (ctx.id !== "") parts.push("--", shq(ctx.id));
|
|
1592
|
+
return parts;
|
|
1593
|
+
}
|
|
1594
|
+
function sessionCommentsParts(ctx) {
|
|
1595
|
+
if (ctx.before !== "" || ctx.after !== "") throw new Error(ctx.messages.beforeAfterOnlyForCompare);
|
|
1596
|
+
if (ctx.id === "") {
|
|
1597
|
+
if (ctx.hasFilters) throw new Error(ctx.messages.filtersNeedId);
|
|
1598
|
+
return sessionListParts({
|
|
1599
|
+
...ctx,
|
|
1600
|
+
hasFilters: false
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
const parts = [
|
|
1604
|
+
ocrCommand(),
|
|
1605
|
+
"session",
|
|
1606
|
+
"comments",
|
|
1607
|
+
"--json",
|
|
1608
|
+
"--repo",
|
|
1609
|
+
shq(ctx.repo)
|
|
1610
|
+
];
|
|
1611
|
+
if (ctx.severities.length > 0) parts.push("--severity", shq(ctx.severities.join(",")));
|
|
1612
|
+
if (ctx.categories.length > 0) parts.push("--category", shq(ctx.categories.join(",")));
|
|
1613
|
+
parts.push("--", shq(ctx.id));
|
|
1614
|
+
return parts;
|
|
1615
|
+
}
|
|
1616
|
+
function sessionShowParts(ctx) {
|
|
1617
|
+
if (ctx.before !== "" || ctx.after !== "") throw new Error(ctx.messages.beforeAfterOnlyForCompare);
|
|
1618
|
+
if (ctx.hasFilters) throw new Error(ctx.messages.filtersOnlyForComments);
|
|
1619
|
+
if (ctx.id === "") return sessionListParts({
|
|
1620
|
+
...ctx,
|
|
1621
|
+
hasFilters: false
|
|
1622
|
+
});
|
|
1623
|
+
return [
|
|
1624
|
+
ocrCommand(),
|
|
1625
|
+
"session",
|
|
1626
|
+
"show",
|
|
1627
|
+
"--json",
|
|
1628
|
+
"--repo",
|
|
1629
|
+
shq(ctx.repo),
|
|
1630
|
+
"--",
|
|
1631
|
+
shq(ctx.id)
|
|
1632
|
+
];
|
|
1633
|
+
}
|
|
1634
|
+
const SESSION_BUILDERS = {
|
|
1635
|
+
list: sessionListParts,
|
|
1636
|
+
compare: sessionCompareParts,
|
|
1637
|
+
export: sessionExportParts,
|
|
1638
|
+
comments: sessionCommentsParts,
|
|
1639
|
+
show: sessionShowParts
|
|
1640
|
+
};
|
|
1641
|
+
function sessionPartsOf(sub, ctx) {
|
|
1642
|
+
return SESSION_BUILDERS[sub](ctx);
|
|
1643
|
+
}
|
|
1644
|
+
function inferSessionAction(requested, ctx) {
|
|
1645
|
+
if (requested !== void 0) return requested;
|
|
1646
|
+
if (ctx.before !== "" || ctx.after !== "") return "compare";
|
|
1647
|
+
return ctx.id === "" ? "list" : "show";
|
|
1648
|
+
}
|
|
1649
|
+
/** 构造 `ocr session list/show/comments/compare/export` 命令(历史评审会话闭环)。 */
|
|
1075
1650
|
function buildSessionCommand(args, messages) {
|
|
1076
1651
|
const repo = assertAbsoluteRoot(args.repo, messages);
|
|
1077
|
-
const id = idValue(args.id, "id", messages);
|
|
1078
1652
|
const action = idValue(args.action, "action", messages);
|
|
1079
1653
|
const requested = SESSION_ACTIONS.find((name) => name === action);
|
|
1080
1654
|
if (action !== "" && requested === void 0) throw new Error(format(messages.invalidAction, {
|
|
1081
1655
|
action,
|
|
1082
1656
|
expected: SESSION_ACTIONS.join("/")
|
|
1083
1657
|
}));
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1658
|
+
const ctx = {
|
|
1659
|
+
repo,
|
|
1660
|
+
id: idValue(args.id, "id", messages),
|
|
1661
|
+
before: idValue(args.before, "before", messages),
|
|
1662
|
+
after: idValue(args.after, "after", messages),
|
|
1663
|
+
severities: severityFilter(args.severity, messages),
|
|
1664
|
+
categories: commaList(args.category, "category", 50, messages),
|
|
1665
|
+
hasFilters: false,
|
|
1666
|
+
limit: args.limit,
|
|
1667
|
+
output: idValue(args.output, "output", messages),
|
|
1668
|
+
messages
|
|
1669
|
+
};
|
|
1670
|
+
ctx.hasFilters = ctx.severities.length > 0 || ctx.categories.length > 0;
|
|
1096
1671
|
return {
|
|
1097
|
-
command:
|
|
1672
|
+
command: sessionPartsOf(inferSessionAction(requested, ctx), ctx).join(" "),
|
|
1098
1673
|
workdir: repo
|
|
1099
1674
|
};
|
|
1100
1675
|
}
|
|
1101
|
-
|
|
1102
|
-
//#region lib/severity.ts
|
|
1676
|
+
// lib/severity.ts
|
|
1103
1677
|
const SEVERITY_ORDER = [
|
|
1104
1678
|
"critical",
|
|
1105
1679
|
"high",
|
|
@@ -1118,7 +1692,7 @@ const SEVERITY_ORDER = [
|
|
|
1118
1692
|
* known 一并回传:归一是「必须发生」的降级,但降级本身不该是**无痕**的——调用方要能
|
|
1119
1693
|
* 统计出「有多少条走了兜底」,否则契约异常被彻底吞掉,聚合里看不出来、排序里也看不出来。
|
|
1120
1694
|
*/
|
|
1121
|
-
function
|
|
1695
|
+
function classifySeverity(severity) {
|
|
1122
1696
|
const matched = SEVERITY_ORDER.find((level) => level === severity.trim().toLowerCase());
|
|
1123
1697
|
return {
|
|
1124
1698
|
level: matched ?? "medium",
|
|
@@ -1127,7 +1701,7 @@ function normalizeSeverity(severity) {
|
|
|
1127
1701
|
}
|
|
1128
1702
|
/** 排序位:critical→high→medium→low(未知已归 medium,永不为 -1)。 */
|
|
1129
1703
|
function severityRank(severity) {
|
|
1130
|
-
return SEVERITY_ORDER.indexOf(
|
|
1704
|
+
return SEVERITY_ORDER.indexOf(classifySeverity(severity).level);
|
|
1131
1705
|
}
|
|
1132
1706
|
/** 聚合评论:按严重度/类别计数。severity 归类以 critical→high→medium→low 基准。 */
|
|
1133
1707
|
function aggregateComments(comments) {
|
|
@@ -1140,10 +1714,11 @@ function aggregateComments(comments) {
|
|
|
1140
1714
|
const categoryCounts = /* @__PURE__ */ new Map();
|
|
1141
1715
|
let unknownSeverityCount = 0;
|
|
1142
1716
|
for (const comment of comments) {
|
|
1143
|
-
const { level, known } =
|
|
1717
|
+
const { level, known } = classifySeverity(comment.severity);
|
|
1144
1718
|
bySeverity[level] += 1;
|
|
1145
1719
|
if (!known) unknownSeverityCount += 1;
|
|
1146
|
-
|
|
1720
|
+
const category = comment.category.trim();
|
|
1721
|
+
if (category !== "") categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + 1);
|
|
1147
1722
|
}
|
|
1148
1723
|
const byCategory = Object.fromEntries(categoryCounts);
|
|
1149
1724
|
return {
|
|
@@ -1153,16 +1728,19 @@ function aggregateComments(comments) {
|
|
|
1153
1728
|
unknownSeverityCount
|
|
1154
1729
|
};
|
|
1155
1730
|
}
|
|
1156
|
-
|
|
1157
|
-
//#region lib/parse.ts
|
|
1731
|
+
// lib/parse.ts
|
|
1158
1732
|
/**
|
|
1159
1733
|
* OCR 评审 JSON 的 status 枚举(判据在 CLI 侧 `cmd/opencodereview/output.go` 的 jsonOutput
|
|
1160
1734
|
* 装配与 `internal/session/manifest.go` 的 TerminalState)。
|
|
1161
1735
|
* 白名单外的一律视为失败:exit 0 不等于评审成功——`{"error":"auth failed"}`
|
|
1162
1736
|
* 这类「没有 status 的错误对象」若被当作 ok:true,模型会把「0 条评论」读成
|
|
1163
|
-
*
|
|
1164
|
-
*
|
|
1165
|
-
*
|
|
1737
|
+
* 「代码干净」,是最坏的一类假阳性。
|
|
1738
|
+
*
|
|
1739
|
+
* `failed` 在名单里,但**单独判成 ok:false**(见 `parseReviewOutput`)。两件事要分开:
|
|
1740
|
+
* ① 名字得认——非零退出时 CLI 照发的那份 manifest 就带 `status:"failed"`,
|
|
1741
|
+
* `withCoverageDetail` 要靠它读出「哪些文件没审成」;不认这个值就等于把失败明细整条丢掉。
|
|
1742
|
+
* ② 认了不等于成功——万一它带着 exit 0 回来,把它当 ok:true 就会把一份全场失败的回执
|
|
1743
|
+
* 摘要成「0 条评论」,那正是本段开头要避免的假阳性。
|
|
1166
1744
|
*
|
|
1167
1745
|
* partial 是 manifest 终态之一:部分文件失败但整体有覆盖、以及 token 预算截断后仍有
|
|
1168
1746
|
* 覆盖,都以 exit 0 + status "partial" 输出。此前白名单漏了它,整场已花费 token 的
|
|
@@ -1176,6 +1754,7 @@ const REVIEW_STATUSES = [
|
|
|
1176
1754
|
"success",
|
|
1177
1755
|
"complete",
|
|
1178
1756
|
"partial",
|
|
1757
|
+
"failed",
|
|
1179
1758
|
"completed_with_warnings",
|
|
1180
1759
|
"completed_with_errors",
|
|
1181
1760
|
"skipped"
|
|
@@ -1195,9 +1774,8 @@ function str(value) {
|
|
|
1195
1774
|
* 故作兜底值。
|
|
1196
1775
|
*/
|
|
1197
1776
|
function nonNegativeInt(value) {
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
return truncated;
|
|
1777
|
+
if (!Number.isSafeInteger(value) || value < 0) return 0;
|
|
1778
|
+
return value;
|
|
1201
1779
|
}
|
|
1202
1780
|
/** 数值字段解析:number 与「数字字符串」都接受(OCR 部分 provider 实测把 start_line
|
|
1203
1781
|
* 输出成 "42",一律当 0 会把真实评论判成「未锚定」而被摘要丢掉),空串/NaN/Infinity/
|
|
@@ -1220,6 +1798,42 @@ const DIAGNOSTIC_DETAIL_LIMIT = 8;
|
|
|
1220
1798
|
function boolOf(value) {
|
|
1221
1799
|
return value === true;
|
|
1222
1800
|
}
|
|
1801
|
+
/** 走 plan 预算/工具轮次上限的两枚警告:它们是「限流截断」的唯一结构化信号。 */
|
|
1802
|
+
const LIMIT_WARNING_TYPES = /* @__PURE__ */ new Set(["review_limit_reached", "token_budget_reached"]);
|
|
1803
|
+
/** 把 project_summary 截到摘要预算内;截断事实单独带出,别让模型把半截当全文。 */
|
|
1804
|
+
const PROJECT_SUMMARY_LIMIT = 4e3;
|
|
1805
|
+
/** `llm` 身份:两个字段都读不出时回 null,而不是造一个空模型。 */
|
|
1806
|
+
function llmOf(raw) {
|
|
1807
|
+
if (!isRecord(raw)) return null;
|
|
1808
|
+
const provider = str(raw["provider"]).trim();
|
|
1809
|
+
const model = str(raw["model"]).trim();
|
|
1810
|
+
if (provider === "" && model === "") return null;
|
|
1811
|
+
return {
|
|
1812
|
+
provider: provider === "" ? null : provider,
|
|
1813
|
+
model
|
|
1814
|
+
};
|
|
1815
|
+
}
|
|
1816
|
+
/** `resume` 回执:字段全空时回 null(CLI 只在真的续审时发这个对象)。 */
|
|
1817
|
+
function retryOf(raw) {
|
|
1818
|
+
if (!isRecord(raw)) return null;
|
|
1819
|
+
const view = {
|
|
1820
|
+
totalRetries: num(raw["total_retries"]),
|
|
1821
|
+
recoveredRequests: num(raw["recovered_requests"]),
|
|
1822
|
+
failedRequests: num(raw["failed_requests"])
|
|
1823
|
+
};
|
|
1824
|
+
return view.totalRetries === 0 && view.recoveredRequests === 0 && view.failedRequests === 0 ? null : view;
|
|
1825
|
+
}
|
|
1826
|
+
function resumeOf(raw) {
|
|
1827
|
+
if (!isRecord(raw)) return null;
|
|
1828
|
+
const view = {
|
|
1829
|
+
resumedFrom: str(raw["resumed_from"]),
|
|
1830
|
+
reusedFiles: num(raw["reused_files"]),
|
|
1831
|
+
rerunFiles: num(raw["rerun_files"]),
|
|
1832
|
+
previousModel: str(raw["previous_model"]),
|
|
1833
|
+
currentModel: str(raw["current_model"])
|
|
1834
|
+
};
|
|
1835
|
+
return view.resumedFrom === "" && view.reusedFiles === 0 && view.rerunFiles === 0 && view.previousModel === "" && view.currentModel === "" ? null : view;
|
|
1836
|
+
}
|
|
1223
1837
|
/**
|
|
1224
1838
|
* 明细行收尾:截断时补一条「另有 N 条已省略」,让读的人知道后面还有,而不是以为看到了全部。
|
|
1225
1839
|
* @param lines - 已渲染的明细行
|
|
@@ -1356,6 +1970,14 @@ function reviewFailure(status, message) {
|
|
|
1356
1970
|
skipped: false,
|
|
1357
1971
|
summary: null,
|
|
1358
1972
|
budgetExceeded: false,
|
|
1973
|
+
limitReached: false,
|
|
1974
|
+
llm: null,
|
|
1975
|
+
projectSummary: null,
|
|
1976
|
+
projectSummaryTruncated: false,
|
|
1977
|
+
resume: null,
|
|
1978
|
+
groupCount: 0,
|
|
1979
|
+
retry: null,
|
|
1980
|
+
traceId: null,
|
|
1359
1981
|
comments: [],
|
|
1360
1982
|
warnings: [],
|
|
1361
1983
|
toolCalls: null,
|
|
@@ -1378,9 +2000,13 @@ function reviewFailure(status, message) {
|
|
|
1378
2000
|
*/
|
|
1379
2001
|
function diagnosticsOf(root, messages) {
|
|
1380
2002
|
const warnings = [];
|
|
2003
|
+
let limitReached = false;
|
|
1381
2004
|
const warningsRaw = root["warnings"];
|
|
1382
2005
|
if (warningsRaw !== void 0) {
|
|
1383
|
-
if (Array.isArray(warningsRaw)) for (const warn of warningsRaw)
|
|
2006
|
+
if (Array.isArray(warningsRaw)) for (const warn of warningsRaw) {
|
|
2007
|
+
warnings.push(warningLineOf(warn, messages));
|
|
2008
|
+
limitReached ||= isRecord(warn) && LIMIT_WARNING_TYPES.has(str(warn["type"]));
|
|
2009
|
+
}
|
|
1384
2010
|
else warnings.push(messages.warningUnreadable);
|
|
1385
2011
|
}
|
|
1386
2012
|
const toolCalls = toolCallsOf(root["tool_calls"], messages);
|
|
@@ -1390,7 +2016,8 @@ function diagnosticsOf(root, messages) {
|
|
|
1390
2016
|
return {
|
|
1391
2017
|
warnings,
|
|
1392
2018
|
toolCalls: toolCalls.view,
|
|
1393
|
-
coverage: coverage.view
|
|
2019
|
+
coverage: coverage.view,
|
|
2020
|
+
limitReached
|
|
1394
2021
|
};
|
|
1395
2022
|
}
|
|
1396
2023
|
/**
|
|
@@ -1424,13 +2051,17 @@ function parseReviewOutput(text, messages) {
|
|
|
1424
2051
|
else comments.push(comment);
|
|
1425
2052
|
}
|
|
1426
2053
|
const skipped = status === "skipped";
|
|
2054
|
+
const ok = status !== "failed";
|
|
2055
|
+
const message = str(root["message"]) || (ok ? "" : messages.ocrStatusFailed);
|
|
1427
2056
|
const sumRaw = root["summary"];
|
|
1428
2057
|
const sum = isRecord(sumRaw) ? sumRaw : void 0;
|
|
1429
2058
|
const diagnostics = diagnosticsOf(root, messages);
|
|
2059
|
+
const rawProjectSummary = str(root["project_summary"]);
|
|
2060
|
+
const projectSummaryTruncated = rawProjectSummary.length > PROJECT_SUMMARY_LIMIT;
|
|
1430
2061
|
return {
|
|
1431
|
-
ok
|
|
2062
|
+
ok,
|
|
1432
2063
|
status,
|
|
1433
|
-
message
|
|
2064
|
+
message,
|
|
1434
2065
|
skipped,
|
|
1435
2066
|
summary: sum ? {
|
|
1436
2067
|
filesReviewed: num(sum["files_reviewed"]),
|
|
@@ -1438,9 +2069,19 @@ function parseReviewOutput(text, messages) {
|
|
|
1438
2069
|
totalTokens: num(sum["total_tokens"]),
|
|
1439
2070
|
inputTokens: num(sum["input_tokens"]),
|
|
1440
2071
|
outputTokens: num(sum["output_tokens"]),
|
|
2072
|
+
cacheReadTokens: num(sum["cache_read_tokens"]),
|
|
2073
|
+
cacheWriteTokens: num(sum["cache_write_tokens"]),
|
|
1441
2074
|
elapsed: str(sum["elapsed"])
|
|
1442
2075
|
} : null,
|
|
1443
2076
|
budgetExceeded: sum === void 0 ? false : boolOf(sum["budget_exceeded"]),
|
|
2077
|
+
limitReached: diagnostics.limitReached,
|
|
2078
|
+
llm: llmOf(root["llm"]),
|
|
2079
|
+
projectSummary: rawProjectSummary === "" ? null : rawProjectSummary.slice(0, PROJECT_SUMMARY_LIMIT),
|
|
2080
|
+
projectSummaryTruncated,
|
|
2081
|
+
resume: resumeOf(root["resume"]),
|
|
2082
|
+
groupCount: Array.isArray(root["groups"]) ? root["groups"].length : 0,
|
|
2083
|
+
retry: retryOf(root["retry_report"]),
|
|
2084
|
+
traceId: str(root["trace_id"]) || null,
|
|
1444
2085
|
comments,
|
|
1445
2086
|
warnings: diagnostics.warnings,
|
|
1446
2087
|
toolCalls: diagnostics.toolCalls,
|
|
@@ -1476,6 +2117,14 @@ function summarizeReview(parsed, messages, plan = DEFAULT_PLAN) {
|
|
|
1476
2117
|
message: parsed.message || null,
|
|
1477
2118
|
summary: parsed.summary ?? null,
|
|
1478
2119
|
budgetExceeded: parsed.budgetExceeded,
|
|
2120
|
+
limitReached: parsed.limitReached,
|
|
2121
|
+
llm: parsed.llm,
|
|
2122
|
+
projectSummary: parsed.projectSummary,
|
|
2123
|
+
projectSummaryTruncated: parsed.projectSummaryTruncated,
|
|
2124
|
+
resume: parsed.resume,
|
|
2125
|
+
groupCount: parsed.groupCount,
|
|
2126
|
+
retry: parsed.retry,
|
|
2127
|
+
traceId: parsed.traceId,
|
|
1479
2128
|
sessionId: parsed.sessionId || null,
|
|
1480
2129
|
aggregation: agg,
|
|
1481
2130
|
totalCommentCount: parsed.comments.length,
|
|
@@ -1539,7 +2188,13 @@ function toDelegateFiles(raw) {
|
|
|
1539
2188
|
}).filter((fileEntry) => fileEntry !== null);
|
|
1540
2189
|
return raw.length > 0 && files.length === 0 ? null : files;
|
|
1541
2190
|
}
|
|
1542
|
-
/** excluded_files 同款门:条目形状与 reviewable 一致,缺 path 即不可用。
|
|
2191
|
+
/** excluded_files 同款门:条目形状与 reviewable 一致,缺 path 即不可用。
|
|
2192
|
+
*
|
|
2193
|
+
* 理由的键名以 CLI 侧 `delegatePreviewFileJSON` 为准,那是 `exclude_reason`
|
|
2194
|
+
* (`delegate_cmd.go` 的 `ExcludeReason string \`json:"exclude_reason,omitempty"\``)。
|
|
2195
|
+
* 读 `reason` 是照自己脑补的形状写的:真回执里那个键不存在,`str()` 把缺失读成空串,
|
|
2196
|
+
* 于是「被排除文件及原因」永远只有文件没有原因——schema 与工具描述都在承诺后者。
|
|
2197
|
+
* `reason` 作回退保留:形如 `{"reason":…}` 的旧形态/别家产物仍读得出来。 */
|
|
1543
2198
|
function toExcludedFiles(raw) {
|
|
1544
2199
|
if (!Array.isArray(raw)) return [];
|
|
1545
2200
|
const files = raw.map((entry) => {
|
|
@@ -1548,7 +2203,7 @@ function toExcludedFiles(raw) {
|
|
|
1548
2203
|
if (entryPath === "") return null;
|
|
1549
2204
|
return {
|
|
1550
2205
|
path: entryPath,
|
|
1551
|
-
reason: str(entry["reason"])
|
|
2206
|
+
reason: str(entry["exclude_reason"]) || str(entry["reason"])
|
|
1552
2207
|
};
|
|
1553
2208
|
}).filter((fileEntry) => fileEntry !== null);
|
|
1554
2209
|
return raw.length > 0 && files.length === 0 ? null : files;
|
|
@@ -1626,73 +2281,738 @@ function parseDelegateRules(text) {
|
|
|
1626
2281
|
groups
|
|
1627
2282
|
};
|
|
1628
2283
|
}
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
* 此前「非 openai-responses 一律 openai」的二值映射会把 anthropic-messages 的
|
|
1645
|
-
* 端点按 OpenAI 线协议打出去——select 成功、OCR 调不通。表外值透传,让
|
|
1646
|
-
* renderSelectedConfig 的白名单给出可读错误。
|
|
1647
|
-
*/
|
|
1648
|
-
const OCR_PROTOCOL_BY_API = /* @__PURE__ */ new Map([
|
|
1649
|
-
["openai-completions", "openai"],
|
|
1650
|
-
["openai-responses", "openai-responses"],
|
|
1651
|
-
["anthropic-messages", "anthropic"]
|
|
1652
|
-
]);
|
|
1653
|
-
/** 对象/数组形态的 `api` 的透传标记:可读,但绝不会被误认成任何合法协议名。 */
|
|
1654
|
-
const API_UNREADABLE = "[unreadable]";
|
|
2284
|
+
/** 预览列表的条数上限:整仓扫描可能列出几千个文件,摘要只留足以判断范围的一截。 */
|
|
2285
|
+
const PREVIEW_FILE_LIMIT = 200;
|
|
2286
|
+
function previewFileOf(raw) {
|
|
2287
|
+
if (!isRecord(raw)) return null;
|
|
2288
|
+
const filePath = str(raw["path"]);
|
|
2289
|
+
if (filePath === "") return null;
|
|
2290
|
+
return {
|
|
2291
|
+
path: filePath,
|
|
2292
|
+
status: str(raw["status"]),
|
|
2293
|
+
insertions: num(raw["insertions"]),
|
|
2294
|
+
deletions: num(raw["deletions"]),
|
|
2295
|
+
willReview: raw["will_review"] === true,
|
|
2296
|
+
excludeReason: str(raw["exclude_reason"])
|
|
2297
|
+
};
|
|
2298
|
+
}
|
|
1655
2299
|
/**
|
|
1656
|
-
*
|
|
2300
|
+
* 解析 `ocr review --preview --format json` / `ocr scan --preview --format json`。
|
|
1657
2301
|
*
|
|
1658
|
-
*
|
|
1659
|
-
*
|
|
1660
|
-
*
|
|
1661
|
-
* - 数字 / 布尔 ⇒ String(),同样落进「表外值」这条正路;
|
|
1662
|
-
* - 对象 / 数组 ⇒ 固定标记。用 String(raw) 会走 Object.prototype.toString 得到
|
|
1663
|
-
* "[object Object]",那既无信息量又看着像协议名;显式标记更能说明「这里读不出来」。
|
|
2302
|
+
* 预览是 `model.Preview` 的裸对象(键 `files`),与 review/scan 的摘要**不是**同一
|
|
2303
|
+
* 形状;把它喂给 parseReviewOutput 只会得到一条解析失败。契约键一个都不命中、或
|
|
2304
|
+
* 条目全是坏形状时判失败,不能报成「0 个文件可审」。
|
|
1664
2305
|
*/
|
|
1665
|
-
function
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
2306
|
+
function parsePreviewOutput(text) {
|
|
2307
|
+
let doc;
|
|
2308
|
+
try {
|
|
2309
|
+
doc = JSON.parse(text);
|
|
2310
|
+
} catch {
|
|
2311
|
+
return {
|
|
2312
|
+
ok: false,
|
|
2313
|
+
view: null
|
|
2314
|
+
};
|
|
2315
|
+
}
|
|
2316
|
+
if (!isRecord(doc) || !Array.isArray(doc["files"])) return {
|
|
2317
|
+
ok: false,
|
|
2318
|
+
view: null
|
|
2319
|
+
};
|
|
2320
|
+
const parsed = doc["files"].map((entry) => previewFileOf(entry)).filter((entry) => entry !== null);
|
|
2321
|
+
if (doc["files"].length > 0 && parsed.length === 0) return {
|
|
2322
|
+
ok: false,
|
|
2323
|
+
view: null
|
|
2324
|
+
};
|
|
2325
|
+
const reviewable = parsed.filter((entry) => entry.willReview);
|
|
2326
|
+
const excluded = parsed.filter((entry) => !entry.willReview);
|
|
2327
|
+
const countOf = (key, fallback) => {
|
|
2328
|
+
const raw = doc[key];
|
|
2329
|
+
return isCount(raw) ? num(raw) : fallback;
|
|
2330
|
+
};
|
|
2331
|
+
return {
|
|
2332
|
+
ok: true,
|
|
2333
|
+
view: {
|
|
2334
|
+
totalFiles: countOf("total_files", parsed.length),
|
|
2335
|
+
reviewableCount: countOf("reviewable_count", reviewable.length),
|
|
2336
|
+
excludedCount: countOf("excluded_count", excluded.length),
|
|
2337
|
+
totalInsertions: num(doc["total_insertions"]),
|
|
2338
|
+
totalDeletions: num(doc["total_deletions"]),
|
|
2339
|
+
reviewableFiles: reviewable.slice(0, PREVIEW_FILE_LIMIT),
|
|
2340
|
+
excludedFiles: excluded.slice(0, PREVIEW_FILE_LIMIT),
|
|
2341
|
+
truncated: reviewable.length > PREVIEW_FILE_LIMIT || excluded.length > PREVIEW_FILE_LIMIT
|
|
2342
|
+
}
|
|
2343
|
+
};
|
|
1670
2344
|
}
|
|
1671
|
-
/**
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
const rec = modelRaw;
|
|
1675
|
-
const id = typeof rec["id"] === "string" ? rec["id"].trim() : "";
|
|
1676
|
-
if (!id) return null;
|
|
1677
|
-
const name = typeof rec["name"] === "string" ? rec["name"].trim() : "";
|
|
2345
|
+
/** 每个桶的展示上限:四桶全铺开会把 compare 变成第二份全量评论。 */
|
|
2346
|
+
const COMPARE_BUCKET_LIMIT = 20;
|
|
2347
|
+
function compareSideOf(raw) {
|
|
1678
2348
|
return {
|
|
1679
|
-
|
|
1680
|
-
|
|
2349
|
+
sessionId: isRecord(raw) ? str(raw["session_id"]) : "",
|
|
2350
|
+
reviewMode: isRecord(raw) ? str(raw["review_mode"]) : "",
|
|
2351
|
+
range: isRecord(raw) ? str(raw["range"]) : ""
|
|
1681
2352
|
};
|
|
1682
2353
|
}
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
if (
|
|
2354
|
+
function compareCommentOf(raw) {
|
|
2355
|
+
const comment = commentOf(raw);
|
|
2356
|
+
if (comment === null) return null;
|
|
2357
|
+
return {
|
|
2358
|
+
location: locationOf(comment),
|
|
2359
|
+
severity: comment.severity,
|
|
2360
|
+
category: comment.category,
|
|
2361
|
+
content: comment.content
|
|
2362
|
+
};
|
|
2363
|
+
}
|
|
2364
|
+
/** 一个 compare 桶:缺失/非数组判整个 compare 失败,部分条目坏只丢该条并计入 dropped。 */
|
|
2365
|
+
function compareBucketOf(raw) {
|
|
2366
|
+
if (!Array.isArray(raw)) return null;
|
|
2367
|
+
return {
|
|
2368
|
+
items: raw.map((entry) => compareCommentOf(entry)).filter((entry) => entry !== null).slice(0, COMPARE_BUCKET_LIMIT),
|
|
2369
|
+
total: raw.length
|
|
2370
|
+
};
|
|
2371
|
+
}
|
|
2372
|
+
/** 解析 `ocr session compare --json`:四个桶与两侧会话标识。 */
|
|
2373
|
+
function parseSessionCompare(text) {
|
|
2374
|
+
let doc;
|
|
2375
|
+
try {
|
|
2376
|
+
doc = JSON.parse(text);
|
|
2377
|
+
} catch {
|
|
2378
|
+
return {
|
|
2379
|
+
ok: false,
|
|
2380
|
+
view: null
|
|
2381
|
+
};
|
|
2382
|
+
}
|
|
2383
|
+
if (!isRecord(doc)) return {
|
|
2384
|
+
ok: false,
|
|
2385
|
+
view: null
|
|
2386
|
+
};
|
|
2387
|
+
const newBucket = compareBucketOf(doc["new"]);
|
|
2388
|
+
const persisting = compareBucketOf(doc["persisting"]);
|
|
2389
|
+
const resolved = compareBucketOf(doc["resolved"]);
|
|
2390
|
+
const notReviewed = compareBucketOf(doc["not_reviewed"]);
|
|
2391
|
+
if (newBucket === null || persisting === null || resolved === null || notReviewed === null) return {
|
|
2392
|
+
ok: false,
|
|
2393
|
+
view: null
|
|
2394
|
+
};
|
|
2395
|
+
const droppedCount = newBucket.total + persisting.total + resolved.total + notReviewed.total - (newBucket.items.length + persisting.items.length + resolved.items.length + notReviewed.items.length);
|
|
2396
|
+
return {
|
|
2397
|
+
ok: true,
|
|
2398
|
+
view: {
|
|
2399
|
+
before: compareSideOf(doc["before"]),
|
|
2400
|
+
after: compareSideOf(doc["after"]),
|
|
2401
|
+
new: newBucket.items,
|
|
2402
|
+
persisting: persisting.items,
|
|
2403
|
+
resolved: resolved.items,
|
|
2404
|
+
notReviewed: notReviewed.items,
|
|
2405
|
+
counts: {
|
|
2406
|
+
new: newBucket.total,
|
|
2407
|
+
persisting: persisting.total,
|
|
2408
|
+
resolved: resolved.total,
|
|
2409
|
+
notReviewed: notReviewed.total
|
|
2410
|
+
},
|
|
2411
|
+
droppedCount
|
|
2412
|
+
}
|
|
2413
|
+
};
|
|
2414
|
+
}
|
|
2415
|
+
// lib/tool-schemas.ts
|
|
2416
|
+
/** 可空 string 字段的 schema 片段(官方子集单类型,null 分支用 oneOf 表达)。 */
|
|
2417
|
+
const NULLABLE_STRING = { oneOf: [{ type: "string" }, { type: "null" }] };
|
|
2418
|
+
/** ocr_review / ocr_scan 的 wait=false 回执(startBackground 的返回对象)。 */
|
|
2419
|
+
const BACKGROUND_RECEIPT_SCHEMA = {
|
|
2420
|
+
type: "object",
|
|
2421
|
+
additionalProperties: false,
|
|
2422
|
+
required: [
|
|
2423
|
+
"started",
|
|
2424
|
+
"mode",
|
|
2425
|
+
"tool",
|
|
2426
|
+
"repo",
|
|
2427
|
+
"processStatus",
|
|
2428
|
+
"jobId",
|
|
2429
|
+
"polling"
|
|
2430
|
+
],
|
|
2431
|
+
properties: {
|
|
2432
|
+
started: {
|
|
2433
|
+
type: "boolean",
|
|
2434
|
+
const: true
|
|
2435
|
+
},
|
|
2436
|
+
mode: {
|
|
2437
|
+
type: "string",
|
|
2438
|
+
const: "background"
|
|
2439
|
+
},
|
|
2440
|
+
tool: { type: "string" },
|
|
2441
|
+
repo: { type: "string" },
|
|
2442
|
+
processStatus: {
|
|
2443
|
+
type: "string",
|
|
2444
|
+
enum: [
|
|
2445
|
+
"running",
|
|
2446
|
+
"completed",
|
|
2447
|
+
"killed"
|
|
2448
|
+
]
|
|
2449
|
+
},
|
|
2450
|
+
jobId: { type: "string" },
|
|
2451
|
+
polling: { type: "string" }
|
|
2452
|
+
}
|
|
2453
|
+
};
|
|
2454
|
+
/** summarizeReview(...) 的对象形状(lib/parse.ts 的 ReviewSummaryView,逐字段对齐)。 */
|
|
2455
|
+
const REVIEW_SUMMARY_SCHEMA = {
|
|
2456
|
+
type: "object",
|
|
2457
|
+
additionalProperties: false,
|
|
2458
|
+
required: [
|
|
2459
|
+
"status",
|
|
2460
|
+
"skipped",
|
|
2461
|
+
"message",
|
|
2462
|
+
"summary",
|
|
2463
|
+
"budgetExceeded",
|
|
2464
|
+
"limitReached",
|
|
2465
|
+
"llm",
|
|
2466
|
+
"projectSummary",
|
|
2467
|
+
"projectSummaryTruncated",
|
|
2468
|
+
"resume",
|
|
2469
|
+
"groupCount",
|
|
2470
|
+
"retry",
|
|
2471
|
+
"traceId",
|
|
2472
|
+
"sessionId",
|
|
2473
|
+
"aggregation",
|
|
2474
|
+
"totalCommentCount",
|
|
2475
|
+
"droppedCount",
|
|
2476
|
+
"invalidCommentCount",
|
|
2477
|
+
"topComments",
|
|
2478
|
+
"warnings",
|
|
2479
|
+
"toolCalls",
|
|
2480
|
+
"coverage",
|
|
2481
|
+
"hint"
|
|
2482
|
+
],
|
|
2483
|
+
properties: {
|
|
2484
|
+
status: {
|
|
2485
|
+
type: "string",
|
|
2486
|
+
enum: [
|
|
2487
|
+
"success",
|
|
2488
|
+
"complete",
|
|
2489
|
+
"partial",
|
|
2490
|
+
"completed_with_warnings",
|
|
2491
|
+
"completed_with_errors",
|
|
2492
|
+
"skipped"
|
|
2493
|
+
]
|
|
2494
|
+
},
|
|
2495
|
+
skipped: { type: "boolean" },
|
|
2496
|
+
message: NULLABLE_STRING,
|
|
2497
|
+
summary: { oneOf: [{ type: "null" }, {
|
|
2498
|
+
type: "object",
|
|
2499
|
+
additionalProperties: false,
|
|
2500
|
+
required: [
|
|
2501
|
+
"filesReviewed",
|
|
2502
|
+
"comments",
|
|
2503
|
+
"totalTokens",
|
|
2504
|
+
"inputTokens",
|
|
2505
|
+
"outputTokens",
|
|
2506
|
+
"cacheReadTokens",
|
|
2507
|
+
"cacheWriteTokens",
|
|
2508
|
+
"elapsed"
|
|
2509
|
+
],
|
|
2510
|
+
properties: {
|
|
2511
|
+
filesReviewed: { type: "number" },
|
|
2512
|
+
comments: { type: "number" },
|
|
2513
|
+
totalTokens: { type: "number" },
|
|
2514
|
+
inputTokens: { type: "number" },
|
|
2515
|
+
outputTokens: { type: "number" },
|
|
2516
|
+
cacheReadTokens: { type: "number" },
|
|
2517
|
+
cacheWriteTokens: { type: "number" },
|
|
2518
|
+
elapsed: { type: "string" }
|
|
2519
|
+
}
|
|
2520
|
+
}] },
|
|
2521
|
+
budgetExceeded: { type: "boolean" },
|
|
2522
|
+
limitReached: { type: "boolean" },
|
|
2523
|
+
llm: { oneOf: [{ type: "null" }, {
|
|
2524
|
+
type: "object",
|
|
2525
|
+
additionalProperties: false,
|
|
2526
|
+
required: ["provider", "model"],
|
|
2527
|
+
properties: {
|
|
2528
|
+
provider: NULLABLE_STRING,
|
|
2529
|
+
model: { type: "string" }
|
|
2530
|
+
}
|
|
2531
|
+
}] },
|
|
2532
|
+
projectSummary: NULLABLE_STRING,
|
|
2533
|
+
projectSummaryTruncated: { type: "boolean" },
|
|
2534
|
+
groupCount: { type: "integer" },
|
|
2535
|
+
retry: { oneOf: [{ type: "null" }, {
|
|
2536
|
+
type: "object",
|
|
2537
|
+
additionalProperties: false,
|
|
2538
|
+
required: [
|
|
2539
|
+
"totalRetries",
|
|
2540
|
+
"recoveredRequests",
|
|
2541
|
+
"failedRequests"
|
|
2542
|
+
],
|
|
2543
|
+
properties: {
|
|
2544
|
+
totalRetries: { type: "number" },
|
|
2545
|
+
recoveredRequests: { type: "number" },
|
|
2546
|
+
failedRequests: { type: "number" }
|
|
2547
|
+
}
|
|
2548
|
+
}] },
|
|
2549
|
+
traceId: NULLABLE_STRING,
|
|
2550
|
+
resume: { oneOf: [{ type: "null" }, {
|
|
2551
|
+
type: "object",
|
|
2552
|
+
additionalProperties: false,
|
|
2553
|
+
required: [
|
|
2554
|
+
"resumedFrom",
|
|
2555
|
+
"reusedFiles",
|
|
2556
|
+
"rerunFiles",
|
|
2557
|
+
"previousModel",
|
|
2558
|
+
"currentModel"
|
|
2559
|
+
],
|
|
2560
|
+
properties: {
|
|
2561
|
+
resumedFrom: { type: "string" },
|
|
2562
|
+
reusedFiles: { type: "number" },
|
|
2563
|
+
rerunFiles: { type: "number" },
|
|
2564
|
+
previousModel: { type: "string" },
|
|
2565
|
+
currentModel: { type: "string" }
|
|
2566
|
+
}
|
|
2567
|
+
}] },
|
|
2568
|
+
sessionId: NULLABLE_STRING,
|
|
2569
|
+
aggregation: {
|
|
2570
|
+
type: "object",
|
|
2571
|
+
additionalProperties: false,
|
|
2572
|
+
required: [
|
|
2573
|
+
"total",
|
|
2574
|
+
"bySeverity",
|
|
2575
|
+
"byCategory",
|
|
2576
|
+
"unknownSeverityCount"
|
|
2577
|
+
],
|
|
2578
|
+
properties: {
|
|
2579
|
+
total: { type: "integer" },
|
|
2580
|
+
unknownSeverityCount: { type: "integer" },
|
|
2581
|
+
bySeverity: {
|
|
2582
|
+
type: "object",
|
|
2583
|
+
additionalProperties: false,
|
|
2584
|
+
required: [
|
|
2585
|
+
"critical",
|
|
2586
|
+
"high",
|
|
2587
|
+
"medium",
|
|
2588
|
+
"low"
|
|
2589
|
+
],
|
|
2590
|
+
properties: {
|
|
2591
|
+
critical: { type: "integer" },
|
|
2592
|
+
high: { type: "integer" },
|
|
2593
|
+
medium: { type: "integer" },
|
|
2594
|
+
low: { type: "integer" }
|
|
2595
|
+
}
|
|
2596
|
+
},
|
|
2597
|
+
byCategory: { type: "object" }
|
|
2598
|
+
}
|
|
2599
|
+
},
|
|
2600
|
+
totalCommentCount: { type: "integer" },
|
|
2601
|
+
droppedCount: { type: "integer" },
|
|
2602
|
+
invalidCommentCount: { type: "integer" },
|
|
2603
|
+
topComments: {
|
|
2604
|
+
type: "array",
|
|
2605
|
+
items: {
|
|
2606
|
+
type: "object",
|
|
2607
|
+
additionalProperties: false,
|
|
2608
|
+
required: [
|
|
2609
|
+
"location",
|
|
2610
|
+
"severity",
|
|
2611
|
+
"category",
|
|
2612
|
+
"content",
|
|
2613
|
+
"suggestion"
|
|
2614
|
+
],
|
|
2615
|
+
properties: {
|
|
2616
|
+
location: { type: "string" },
|
|
2617
|
+
severity: { type: "string" },
|
|
2618
|
+
category: { type: "string" },
|
|
2619
|
+
content: { type: "string" },
|
|
2620
|
+
suggestion: NULLABLE_STRING
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
},
|
|
2624
|
+
warnings: {
|
|
2625
|
+
type: "array",
|
|
2626
|
+
items: { type: "string" }
|
|
2627
|
+
},
|
|
2628
|
+
toolCalls: { oneOf: [{ type: "null" }, {
|
|
2629
|
+
type: "object",
|
|
2630
|
+
additionalProperties: false,
|
|
2631
|
+
required: [
|
|
2632
|
+
"total",
|
|
2633
|
+
"failure",
|
|
2634
|
+
"failures"
|
|
2635
|
+
],
|
|
2636
|
+
properties: {
|
|
2637
|
+
total: { type: "integer" },
|
|
2638
|
+
failure: { type: "integer" },
|
|
2639
|
+
failures: {
|
|
2640
|
+
type: "array",
|
|
2641
|
+
items: { type: "string" }
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
}] },
|
|
2645
|
+
coverage: { oneOf: [{ type: "null" }, {
|
|
2646
|
+
type: "object",
|
|
2647
|
+
additionalProperties: false,
|
|
2648
|
+
required: [
|
|
2649
|
+
"selected",
|
|
2650
|
+
"completed",
|
|
2651
|
+
"reused",
|
|
2652
|
+
"failed",
|
|
2653
|
+
"waived",
|
|
2654
|
+
"failedItems"
|
|
2655
|
+
],
|
|
2656
|
+
properties: {
|
|
2657
|
+
selected: { type: "integer" },
|
|
2658
|
+
completed: { type: "integer" },
|
|
2659
|
+
reused: { type: "integer" },
|
|
2660
|
+
failed: { type: "integer" },
|
|
2661
|
+
waived: { type: "integer" },
|
|
2662
|
+
failedItems: {
|
|
2663
|
+
type: "array",
|
|
2664
|
+
items: { type: "string" }
|
|
2665
|
+
}
|
|
2666
|
+
}
|
|
2667
|
+
}] },
|
|
2668
|
+
hint: { type: "string" }
|
|
2669
|
+
}
|
|
2670
|
+
};
|
|
2671
|
+
/** review/scan `preview:true` 的文件清单对象(lib/parse.ts 的 PreviewView)。 */
|
|
2672
|
+
const PREVIEW_FILE_SCHEMA = {
|
|
2673
|
+
type: "object",
|
|
2674
|
+
additionalProperties: false,
|
|
2675
|
+
required: [
|
|
2676
|
+
"path",
|
|
2677
|
+
"status",
|
|
2678
|
+
"insertions",
|
|
2679
|
+
"deletions",
|
|
2680
|
+
"willReview",
|
|
2681
|
+
"excludeReason"
|
|
2682
|
+
],
|
|
2683
|
+
properties: {
|
|
2684
|
+
path: { type: "string" },
|
|
2685
|
+
status: { type: "string" },
|
|
2686
|
+
insertions: { type: "number" },
|
|
2687
|
+
deletions: { type: "number" },
|
|
2688
|
+
willReview: { type: "boolean" },
|
|
2689
|
+
excludeReason: { type: "string" }
|
|
2690
|
+
}
|
|
2691
|
+
};
|
|
2692
|
+
/** ocr_review / ocr_scan 的输出:wait=false = 后台回执对象;前台成功 = 摘要对象;
|
|
2693
|
+
* preview=true = 文件预览对象;前台解析失败 = 一段诚实的说明字符串。四者恰一。 */
|
|
2694
|
+
const REVIEW_OUTPUT_SCHEMA = { oneOf: [
|
|
2695
|
+
BACKGROUND_RECEIPT_SCHEMA,
|
|
2696
|
+
REVIEW_SUMMARY_SCHEMA,
|
|
2697
|
+
{
|
|
2698
|
+
type: "object",
|
|
2699
|
+
additionalProperties: false,
|
|
2700
|
+
required: [
|
|
2701
|
+
"totalFiles",
|
|
2702
|
+
"reviewableCount",
|
|
2703
|
+
"excludedCount",
|
|
2704
|
+
"totalInsertions",
|
|
2705
|
+
"totalDeletions",
|
|
2706
|
+
"reviewableFiles",
|
|
2707
|
+
"excludedFiles",
|
|
2708
|
+
"truncated",
|
|
2709
|
+
"hint"
|
|
2710
|
+
],
|
|
2711
|
+
properties: {
|
|
2712
|
+
totalFiles: { type: "number" },
|
|
2713
|
+
reviewableCount: { type: "number" },
|
|
2714
|
+
excludedCount: { type: "number" },
|
|
2715
|
+
totalInsertions: { type: "number" },
|
|
2716
|
+
totalDeletions: { type: "number" },
|
|
2717
|
+
reviewableFiles: {
|
|
2718
|
+
type: "array",
|
|
2719
|
+
items: PREVIEW_FILE_SCHEMA
|
|
2720
|
+
},
|
|
2721
|
+
excludedFiles: {
|
|
2722
|
+
type: "array",
|
|
2723
|
+
items: PREVIEW_FILE_SCHEMA
|
|
2724
|
+
},
|
|
2725
|
+
truncated: { type: "boolean" },
|
|
2726
|
+
hint: { type: "string" }
|
|
2727
|
+
}
|
|
2728
|
+
},
|
|
2729
|
+
{ type: "string" }
|
|
2730
|
+
] };
|
|
2731
|
+
/** ocr_delegate_preview:成功 = 清单对象;解析失败 = 说明字符串。 */
|
|
2732
|
+
const PREVIEW_OUTPUT_SCHEMA = { oneOf: [{
|
|
2733
|
+
type: "object",
|
|
2734
|
+
additionalProperties: false,
|
|
2735
|
+
required: [
|
|
2736
|
+
"mode",
|
|
2737
|
+
"repository",
|
|
2738
|
+
"mergeBase",
|
|
2739
|
+
"reviewableCount",
|
|
2740
|
+
"insertions",
|
|
2741
|
+
"deletions",
|
|
2742
|
+
"reviewableFiles",
|
|
2743
|
+
"excludedFiles",
|
|
2744
|
+
"hint"
|
|
2745
|
+
],
|
|
2746
|
+
properties: {
|
|
2747
|
+
mode: { type: "string" },
|
|
2748
|
+
repository: { type: "string" },
|
|
2749
|
+
mergeBase: NULLABLE_STRING,
|
|
2750
|
+
reviewableCount: { type: "integer" },
|
|
2751
|
+
insertions: { type: "number" },
|
|
2752
|
+
deletions: { type: "number" },
|
|
2753
|
+
reviewableFiles: {
|
|
2754
|
+
type: "array",
|
|
2755
|
+
items: {
|
|
2756
|
+
type: "object",
|
|
2757
|
+
additionalProperties: false,
|
|
2758
|
+
required: [
|
|
2759
|
+
"path",
|
|
2760
|
+
"status",
|
|
2761
|
+
"insertions",
|
|
2762
|
+
"deletions"
|
|
2763
|
+
],
|
|
2764
|
+
properties: {
|
|
2765
|
+
path: { type: "string" },
|
|
2766
|
+
status: { type: "string" },
|
|
2767
|
+
insertions: { type: "number" },
|
|
2768
|
+
deletions: { type: "number" }
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2771
|
+
},
|
|
2772
|
+
excludedFiles: {
|
|
2773
|
+
type: "array",
|
|
2774
|
+
items: {
|
|
2775
|
+
type: "object",
|
|
2776
|
+
additionalProperties: false,
|
|
2777
|
+
required: ["path", "reason"],
|
|
2778
|
+
properties: {
|
|
2779
|
+
path: { type: "string" },
|
|
2780
|
+
reason: { type: "string" }
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
},
|
|
2784
|
+
hint: { type: "string" }
|
|
2785
|
+
}
|
|
2786
|
+
}, { type: "string" }] };
|
|
2787
|
+
/** ocr_delegate_rule:成功 = 分组对象;解析失败 = 说明字符串。 */
|
|
2788
|
+
const RULES_OUTPUT_SCHEMA = { oneOf: [{
|
|
2789
|
+
type: "object",
|
|
2790
|
+
additionalProperties: false,
|
|
2791
|
+
required: [
|
|
2792
|
+
"groupCount",
|
|
2793
|
+
"groups",
|
|
2794
|
+
"hint"
|
|
2795
|
+
],
|
|
2796
|
+
properties: {
|
|
2797
|
+
groupCount: { type: "integer" },
|
|
2798
|
+
groups: {
|
|
2799
|
+
type: "array",
|
|
2800
|
+
items: {
|
|
2801
|
+
type: "object",
|
|
2802
|
+
additionalProperties: false,
|
|
2803
|
+
required: [
|
|
2804
|
+
"source",
|
|
2805
|
+
"pattern",
|
|
2806
|
+
"files",
|
|
2807
|
+
"rule"
|
|
2808
|
+
],
|
|
2809
|
+
properties: {
|
|
2810
|
+
source: { type: "string" },
|
|
2811
|
+
pattern: { type: "string" },
|
|
2812
|
+
files: {
|
|
2813
|
+
type: "array",
|
|
2814
|
+
items: { type: "string" }
|
|
2815
|
+
},
|
|
2816
|
+
rule: { type: "string" }
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
},
|
|
2820
|
+
hint: { type: "string" }
|
|
2821
|
+
}
|
|
2822
|
+
}, { type: "string" }] };
|
|
2823
|
+
/** ocr_session:sessionOutput 本身就是一手透传的字符串(诚实的值),外层仍是对象
|
|
2824
|
+
* 好让 hint 有处安放。 */
|
|
2825
|
+
const SESSION_RAW_SCHEMA = {
|
|
2826
|
+
type: "object",
|
|
2827
|
+
additionalProperties: false,
|
|
2828
|
+
required: ["sessionOutput", "hint"],
|
|
2829
|
+
properties: {
|
|
2830
|
+
sessionOutput: { type: "string" },
|
|
2831
|
+
hint: { type: "string" }
|
|
2832
|
+
}
|
|
2833
|
+
};
|
|
2834
|
+
/** compare 的结构化四桶(lib/parse.ts 的 CompareView)。 */
|
|
2835
|
+
const COMPARE_COMMENT_SCHEMA = {
|
|
2836
|
+
type: "object",
|
|
2837
|
+
additionalProperties: false,
|
|
2838
|
+
required: [
|
|
2839
|
+
"location",
|
|
2840
|
+
"severity",
|
|
2841
|
+
"category",
|
|
2842
|
+
"content"
|
|
2843
|
+
],
|
|
2844
|
+
properties: {
|
|
2845
|
+
location: { type: "string" },
|
|
2846
|
+
severity: { type: "string" },
|
|
2847
|
+
category: { type: "string" },
|
|
2848
|
+
content: { type: "string" }
|
|
2849
|
+
}
|
|
2850
|
+
};
|
|
2851
|
+
const COMPARE_SIDE_SCHEMA = {
|
|
2852
|
+
type: "object",
|
|
2853
|
+
additionalProperties: false,
|
|
2854
|
+
required: [
|
|
2855
|
+
"sessionId",
|
|
2856
|
+
"reviewMode",
|
|
2857
|
+
"range"
|
|
2858
|
+
],
|
|
2859
|
+
properties: {
|
|
2860
|
+
sessionId: { type: "string" },
|
|
2861
|
+
reviewMode: { type: "string" },
|
|
2862
|
+
range: { type: "string" }
|
|
2863
|
+
}
|
|
2864
|
+
};
|
|
2865
|
+
const SESSION_OUTPUT_SCHEMA = { oneOf: [SESSION_RAW_SCHEMA, {
|
|
2866
|
+
type: "object",
|
|
2867
|
+
additionalProperties: false,
|
|
2868
|
+
required: [
|
|
2869
|
+
"before",
|
|
2870
|
+
"after",
|
|
2871
|
+
"new",
|
|
2872
|
+
"persisting",
|
|
2873
|
+
"resolved",
|
|
2874
|
+
"notReviewed",
|
|
2875
|
+
"counts",
|
|
2876
|
+
"droppedCount",
|
|
2877
|
+
"hint"
|
|
2878
|
+
],
|
|
2879
|
+
properties: {
|
|
2880
|
+
before: COMPARE_SIDE_SCHEMA,
|
|
2881
|
+
after: COMPARE_SIDE_SCHEMA,
|
|
2882
|
+
new: {
|
|
2883
|
+
type: "array",
|
|
2884
|
+
items: COMPARE_COMMENT_SCHEMA
|
|
2885
|
+
},
|
|
2886
|
+
persisting: {
|
|
2887
|
+
type: "array",
|
|
2888
|
+
items: COMPARE_COMMENT_SCHEMA
|
|
2889
|
+
},
|
|
2890
|
+
resolved: {
|
|
2891
|
+
type: "array",
|
|
2892
|
+
items: COMPARE_COMMENT_SCHEMA
|
|
2893
|
+
},
|
|
2894
|
+
notReviewed: {
|
|
2895
|
+
type: "array",
|
|
2896
|
+
items: COMPARE_COMMENT_SCHEMA
|
|
2897
|
+
},
|
|
2898
|
+
counts: {
|
|
2899
|
+
type: "object",
|
|
2900
|
+
additionalProperties: false,
|
|
2901
|
+
required: [
|
|
2902
|
+
"new",
|
|
2903
|
+
"persisting",
|
|
2904
|
+
"resolved",
|
|
2905
|
+
"notReviewed"
|
|
2906
|
+
],
|
|
2907
|
+
properties: {
|
|
2908
|
+
new: { type: "integer" },
|
|
2909
|
+
persisting: { type: "integer" },
|
|
2910
|
+
resolved: { type: "integer" },
|
|
2911
|
+
notReviewed: { type: "integer" }
|
|
2912
|
+
}
|
|
2913
|
+
},
|
|
2914
|
+
droppedCount: { type: "integer" },
|
|
2915
|
+
hint: { type: "string" }
|
|
2916
|
+
}
|
|
2917
|
+
}] };
|
|
2918
|
+
/** rules check / llm 这类只读文本诊断工具:一手输出 + hint。 */
|
|
2919
|
+
const TEXT_OUTPUT_SCHEMA = {
|
|
2920
|
+
type: "object",
|
|
2921
|
+
additionalProperties: false,
|
|
2922
|
+
required: ["output", "hint"],
|
|
2923
|
+
properties: {
|
|
2924
|
+
output: { type: "string" },
|
|
2925
|
+
hint: { type: "string" }
|
|
2926
|
+
}
|
|
2927
|
+
};
|
|
2928
|
+
// lib/routing.ts
|
|
2929
|
+
const ROUTING_NAME = "ocr-review-routing";
|
|
2930
|
+
const ROUTING_ORDER = 1555;
|
|
2931
|
+
// lib/provider-projection.ts
|
|
2932
|
+
/** llm-pi-ai 命名空间里 provider 表的字段名。 */
|
|
2933
|
+
const PROVIDERS_FIELD = "providers";
|
|
2934
|
+
/**
|
|
2935
|
+
* llm-pi-ai 的 `api` 协议 → OCR config 的 `protocol` 映射。
|
|
2936
|
+
*
|
|
2937
|
+
* 两侧枚举的对照(dsh packages/llm/llm-pi-ai/src/provider.ts:44-48 的 PROTOCOLS
|
|
2938
|
+
* 对 OCR config_cmd.go:796 的 "Protocol values: anthropic, anthropic-bedrock,
|
|
2939
|
+
* openai, openai-responses"):openai-completions 与 OCR 的 openai 是同一条
|
|
2940
|
+
* OpenAI Chat Completions 线协议;anthropic-messages 即 OCR 的 anthropic。
|
|
2941
|
+
* 此前「非 openai-responses 一律 openai」的二值映射会把 anthropic-messages 的
|
|
2942
|
+
* 端点按 OpenAI 线协议打出去——select 成功、OCR 调不通。表外值透传,让
|
|
2943
|
+
* renderSelectedConfig 的白名单给出可读错误。
|
|
2944
|
+
*/
|
|
2945
|
+
/** Map 而非对象字面量:查表键来自用户可写的 settings 值,对象会继承 Object.prototype,
|
|
2946
|
+
* 于是 api="constructor" 取到 Object 这个构造函数、"__proto__" 取到 Object.prototype,
|
|
2947
|
+
* `?? api` 的原样透传兜底失效——protocol 被写成函数/对象,直接违反 `DshProvider.protocol
|
|
2948
|
+
* 的 string 声明,还会让 apply 侧的白名单错误文案里出现 "function toString() { [native
|
|
2949
|
+
* code] }"。Map.get 不查原型链,这两类键安全 miss 并落回「原样透传」这条正路。
|
|
2950
|
+
* provider 名那一侧的同款防护见文件下方的 UNUSABLE_PROVIDER_NAMES。 */
|
|
2951
|
+
const OCR_PROTOCOL_BY_API = /* @__PURE__ */ new Map([
|
|
2952
|
+
["openai-completions", "openai"],
|
|
2953
|
+
["openai-responses", "openai-responses"],
|
|
2954
|
+
["anthropic-messages", "anthropic"]
|
|
2955
|
+
]);
|
|
2956
|
+
/** 不能落进 config.json `custom_providers.<name>` 的 provider 名:`__proto__` 在普通对象
|
|
2957
|
+
* 上是 setter(赋值被静默忽略、写入凭空丢失),`constructor` / `prototype` 是继承属性
|
|
2958
|
+
* 名,与写侧的普通对象赋值组合都不是普通自有键。空名/全空白名单独由 trim 判据拦。 */
|
|
2959
|
+
const UNUSABLE_PROVIDER_NAMES = /* @__PURE__ */ new Set([
|
|
2960
|
+
"__proto__",
|
|
2961
|
+
"constructor",
|
|
2962
|
+
"prototype"
|
|
2963
|
+
]);
|
|
2964
|
+
/** 1.13.3 的 `validateProviderName` 口径:点号分段不能为空或全空白。 */
|
|
2965
|
+
function isUsableProviderName(name) {
|
|
2966
|
+
if (UNUSABLE_PROVIDER_NAMES.has(name)) return false;
|
|
2967
|
+
return name.split(".").every((segment) => segment.trim() !== "");
|
|
2968
|
+
}
|
|
2969
|
+
/** 对象/数组形态的 `api` 的透传标记:可读,但绝不会被误认成任何合法协议名。 */
|
|
2970
|
+
const API_UNREADABLE = "[unreadable]";
|
|
2971
|
+
/**
|
|
2972
|
+
* settings 里的 `api` 字段 → 协议查表用的键;「未声明」以 null 表达。
|
|
2973
|
+
*
|
|
2974
|
+
* 四种形态必须分开,否则畸形声明会被静默降级成「能用的」配置:
|
|
2975
|
+
* - 未声明(undefined / null)⇒ null,交给 llm-pi-ai 的 openai-completions 兜底语义;
|
|
2976
|
+
* - 字符串 ⇒ trim 后原样,**含空串**(表外值原样透传,由写侧白名单拒绝——显式
|
|
2977
|
+
* `api: ""` 在上游同样不走 `??` 默认,兜底它等于把畸形声明改写成可用配置);
|
|
2978
|
+
* - 数字 / 布尔 ⇒ String(),同样落进「表外值」这条正路;
|
|
2979
|
+
* - 对象 / 数组 ⇒ 固定标记。用 String(raw) 会走 Object.prototype.toString 得到
|
|
2980
|
+
* "[object Object]",那既无信息量又看着像协议名;显式标记更能说明「这里读不出来」。
|
|
2981
|
+
*/
|
|
2982
|
+
function apiKeyOf(raw) {
|
|
2983
|
+
if (raw === void 0 || raw === null) return null;
|
|
2984
|
+
if (typeof raw === "string") return raw.trim();
|
|
2985
|
+
if (typeof raw === "number" || typeof raw === "boolean") return String(raw);
|
|
2986
|
+
return API_UNREADABLE;
|
|
2987
|
+
}
|
|
2988
|
+
/** 原始 model 对象 → DshModel(缺 id 视为无效,返回 null)。 */
|
|
2989
|
+
function modelFromRaw(modelRaw) {
|
|
2990
|
+
if (!isRecord(modelRaw)) return null;
|
|
2991
|
+
const rec = modelRaw;
|
|
2992
|
+
const id = typeof rec["id"] === "string" ? rec["id"].trim() : "";
|
|
2993
|
+
if (!id) return null;
|
|
2994
|
+
const name = typeof rec["name"] === "string" ? rec["name"].trim() : "";
|
|
2995
|
+
return {
|
|
2996
|
+
id,
|
|
2997
|
+
name: name === "" ? id : name
|
|
2998
|
+
};
|
|
2999
|
+
}
|
|
3000
|
+
/** 原始 provider 对象 → DshProvider(缺 baseURL 或形状不合视为无效,返回 null)。 */
|
|
3001
|
+
function providerFromRaw(name, raw) {
|
|
3002
|
+
if (!isRecord(raw)) return null;
|
|
1686
3003
|
const entry = raw;
|
|
1687
3004
|
const apiKeyEnv = typeof entry["apiKeyEnv"] === "string" && entry["apiKeyEnv"].trim() ? entry["apiKeyEnv"].trim() : null;
|
|
1688
3005
|
const api = apiKeyOf(entry["api"]);
|
|
1689
|
-
const protocol = api ===
|
|
3006
|
+
const protocol = api === null ? "openai" : OCR_PROTOCOL_BY_API.get(api) ?? api;
|
|
1690
3007
|
const baseURL = typeof entry["baseURL"] === "string" ? entry["baseURL"].trim() : "";
|
|
1691
|
-
if (!baseURL) return null;
|
|
1692
|
-
const
|
|
3008
|
+
if (!baseURL || !/^https?:\/\//iu.test(baseURL)) return null;
|
|
3009
|
+
const modelsRaw = Array.isArray(entry["models"]) ? entry["models"] : [];
|
|
3010
|
+
const models = modelsRaw.map((model) => modelFromRaw(model)).filter((model) => model !== null);
|
|
3011
|
+
if (modelsRaw.length > 0 && models.length === 0) return null;
|
|
3012
|
+
const displayName = typeof entry["displayName"] === "string" ? entry["displayName"].trim() : "";
|
|
1693
3013
|
return {
|
|
1694
3014
|
name,
|
|
1695
|
-
displayName:
|
|
3015
|
+
displayName: displayName === "" ? name : displayName,
|
|
1696
3016
|
apiKeyEnv,
|
|
1697
3017
|
protocol,
|
|
1698
3018
|
baseURL,
|
|
@@ -1711,13 +3031,12 @@ function providersFromSettingsValue(value) {
|
|
|
1711
3031
|
if (!isRecord(providers)) return [];
|
|
1712
3032
|
const out = [];
|
|
1713
3033
|
for (const [name, raw] of Object.entries(providers)) {
|
|
1714
|
-
const parsed = providerFromRaw(name, raw);
|
|
3034
|
+
const parsed = isUsableProviderName(name) ? providerFromRaw(name, raw) : null;
|
|
1715
3035
|
if (parsed !== null) out.push(parsed);
|
|
1716
3036
|
}
|
|
1717
3037
|
return out;
|
|
1718
3038
|
}
|
|
1719
|
-
|
|
1720
|
-
//#region lib/provider-source.ts
|
|
3039
|
+
// lib/provider-source.ts
|
|
1721
3040
|
/** provider 清单所在的官方 settings 命名空间(base bundle 的 pi-ai 适配器注册)。 */
|
|
1722
3041
|
const PI_AI_NAMESPACE = "llm-pi-ai";
|
|
1723
3042
|
const SOURCE_READY = Object.freeze({
|
|
@@ -1762,7 +3081,19 @@ function readProviders(gateway, messages) {
|
|
|
1762
3081
|
}
|
|
1763
3082
|
};
|
|
1764
3083
|
}
|
|
1765
|
-
|
|
3084
|
+
let hit;
|
|
3085
|
+
try {
|
|
3086
|
+
if (!Array.isArray(descriptors)) throw new TypeError(`ctx.settings.describe() 没有给出数组(收到 ${typeof descriptors})`);
|
|
3087
|
+
hit = descriptors.find((descriptor) => descriptor.ns === PI_AI_NAMESPACE);
|
|
3088
|
+
} catch (error) {
|
|
3089
|
+
return {
|
|
3090
|
+
providers: [],
|
|
3091
|
+
source: {
|
|
3092
|
+
status: SOURCE_SETTINGS_FAILED,
|
|
3093
|
+
message: format(messages.sourceSettingsFailed, { message: errorText(error) })
|
|
3094
|
+
}
|
|
3095
|
+
};
|
|
3096
|
+
}
|
|
1766
3097
|
if (hit === void 0) return {
|
|
1767
3098
|
providers: [],
|
|
1768
3099
|
source: {
|
|
@@ -1770,7 +3101,8 @@ function readProviders(gateway, messages) {
|
|
|
1770
3101
|
message: format(messages.sourceNamespaceUnavailable, { ns: PI_AI_NAMESPACE })
|
|
1771
3102
|
}
|
|
1772
3103
|
};
|
|
1773
|
-
|
|
3104
|
+
const providersValue = fieldOf(hit.value, "providers");
|
|
3105
|
+
if (!isRecord(providersValue)) return {
|
|
1774
3106
|
providers: [],
|
|
1775
3107
|
source: {
|
|
1776
3108
|
status: "namespace-unavailable",
|
|
@@ -1809,15 +3141,19 @@ async function markKeyFlags(gateway, providers) {
|
|
|
1809
3141
|
...providerCfg,
|
|
1810
3142
|
hasKey: false
|
|
1811
3143
|
};
|
|
1812
|
-
|
|
3144
|
+
let hasKey;
|
|
3145
|
+
try {
|
|
3146
|
+
hasKey = await gateway.credentialConfigured(ref);
|
|
3147
|
+
} catch {
|
|
3148
|
+
hasKey = false;
|
|
3149
|
+
}
|
|
1813
3150
|
return {
|
|
1814
3151
|
...providerCfg,
|
|
1815
3152
|
hasKey
|
|
1816
3153
|
};
|
|
1817
3154
|
}));
|
|
1818
3155
|
}
|
|
1819
|
-
|
|
1820
|
-
//#region lib/config-store.ts
|
|
3156
|
+
// lib/config-store.ts
|
|
1821
3157
|
/** api_key_cmd 脚本名(pluginDir 兜底路径的唯一拼法来源)。 */
|
|
1822
3158
|
const GET_CRED_SCRIPT = path.join("scripts", "get-cred.mjs");
|
|
1823
3159
|
/** 未知值 → 安全字符串(非字符串给 '')。 */
|
|
@@ -1848,7 +3184,12 @@ function loadOcrConfig(configJsonText) {
|
|
|
1848
3184
|
raw: doc
|
|
1849
3185
|
};
|
|
1850
3186
|
}
|
|
1851
|
-
/** config.json
|
|
3187
|
+
/** 读侧专用:config.json 的当前文本,读不到一律空串。
|
|
3188
|
+
*
|
|
3189
|
+
* 只给「读侧要宽容」的那一类调用用(设置卡打开时渲染当前选择:盘上文件坏了也必须
|
|
3190
|
+
* 能打开卡片,用户正是靠卡片去修)。**写侧不能用它**——把 EACCES/EIO 折成空串等于
|
|
3191
|
+
* 把「读不到」当成「文件不存在」,随后整份覆盖就丢掉了用户既有的全部配置;写侧走
|
|
3192
|
+
* readExistingText。 */
|
|
1852
3193
|
function readText(configPath) {
|
|
1853
3194
|
try {
|
|
1854
3195
|
return readFileSync(configPath, "utf8");
|
|
@@ -1856,6 +3197,27 @@ function readText(configPath) {
|
|
|
1856
3197
|
return "";
|
|
1857
3198
|
}
|
|
1858
3199
|
}
|
|
3200
|
+
/** 文件不存在是正常态(首写、被用户删掉),其余读失败不是。 */
|
|
3201
|
+
function isNotFound$1(error) {
|
|
3202
|
+
return isRecord(error) && fieldOf(error, "code") === "ENOENT";
|
|
3203
|
+
}
|
|
3204
|
+
/** 写侧专用:把「文件不存在 / 内容为空」与「文件读不动」分开。
|
|
3205
|
+
*
|
|
3206
|
+
* 前者返回空串,`parseExistingConfig` 依约给出 `{}`,写出来的就是只含本次选择的
|
|
3207
|
+
* 新文档;后者必须抛错——这一层往下是「读 → 改 → 整份写回」,把读不到当成空文档
|
|
3208
|
+
* 会让一次瞬时 EIO 变成用户全部 providers / keys / mcp_servers / language 被覆盖,
|
|
3209
|
+
* 而备份(`.bak`)也救不了第二次。 */
|
|
3210
|
+
function readExistingText(configPath, messages) {
|
|
3211
|
+
try {
|
|
3212
|
+
return readFileSync(configPath, "utf8");
|
|
3213
|
+
} catch (error) {
|
|
3214
|
+
if (isNotFound$1(error)) return "";
|
|
3215
|
+
throw new Error(format(messages.configUnreadable, {
|
|
3216
|
+
where: configPath,
|
|
3217
|
+
cause: errorText(error)
|
|
3218
|
+
}), { cause: error });
|
|
3219
|
+
}
|
|
3220
|
+
}
|
|
1859
3221
|
/**
|
|
1860
3222
|
* api_key_cmd 内嵌值的转义。POSIX 走单引号(ocr 在 Unix 用 `sh -c` 跑 api_key_cmd,
|
|
1861
3223
|
* keycmd_unix.go);Windows 的 ocr 用 **cmd.exe** 跑它(keycmd_windows.go),单引号
|
|
@@ -2179,8 +3541,7 @@ function repointDanglingKeyScripts(doc, current, messages) {
|
|
|
2179
3541
|
return [name];
|
|
2180
3542
|
}) };
|
|
2181
3543
|
}
|
|
2182
|
-
|
|
2183
|
-
//#region lib/ocr-config.ts
|
|
3544
|
+
// lib/ocr-config.ts
|
|
2184
3545
|
/** 外部 ocr CLI 的默认配置目录/文件名(宿主服务管不到,只能由本包直读直写)。 */
|
|
2185
3546
|
const OCR_CONFIG_DIR = ".opencodereview";
|
|
2186
3547
|
const OCR_CONFIG_FILE = "config.json";
|
|
@@ -2301,7 +3662,7 @@ async function applyProviderSelection(gateway, paths, selection, messages) {
|
|
|
2301
3662
|
const apiKeyCmd = buildApiKeyCmd(scriptPath, target.apiKeyEnv, resolveDshHome(), messages);
|
|
2302
3663
|
await updateConfigLocked(paths.ocrConfigJson, async () => {
|
|
2303
3664
|
const next = renderSelectedConfig({
|
|
2304
|
-
existingText:
|
|
3665
|
+
existingText: readExistingText(paths.ocrConfigJson, messages),
|
|
2305
3666
|
configLabel: paths.ocrConfigJson,
|
|
2306
3667
|
providers,
|
|
2307
3668
|
provider,
|
|
@@ -2345,7 +3706,7 @@ function migrateEntry(entry, envKey, context) {
|
|
|
2345
3706
|
* 返回 { migrated: string[]; skippedUnknown: string[] }。
|
|
2346
3707
|
*/
|
|
2347
3708
|
async function migratePlaintextKeys(gateway, paths, messages) {
|
|
2348
|
-
const precheck =
|
|
3709
|
+
const precheck = readExistingText(paths.ocrConfigJson, messages);
|
|
2349
3710
|
if (!precheck.trim()) return {
|
|
2350
3711
|
migrated: [],
|
|
2351
3712
|
skippedUnknown: []
|
|
@@ -2372,7 +3733,7 @@ async function migratePlaintextKeys(gateway, paths, messages) {
|
|
|
2372
3733
|
messages
|
|
2373
3734
|
};
|
|
2374
3735
|
return await updateConfigLocked(paths.ocrConfigJson, async () => {
|
|
2375
|
-
const doc = parseExistingConfig(
|
|
3736
|
+
const doc = parseExistingConfig(readExistingText(paths.ocrConfigJson, messages), paths.ocrConfigJson, messages);
|
|
2376
3737
|
const customsRaw = fieldOf(doc, "custom_providers");
|
|
2377
3738
|
if (!isRecord(customsRaw)) return {
|
|
2378
3739
|
migrated: [],
|
|
@@ -2420,14 +3781,23 @@ async function ensureKeyScriptPointer(paths, messages) {
|
|
|
2420
3781
|
script: scriptPath,
|
|
2421
3782
|
dshHome: resolveDshHome()
|
|
2422
3783
|
};
|
|
2423
|
-
|
|
3784
|
+
let text;
|
|
3785
|
+
try {
|
|
3786
|
+
text = readExistingText(paths.ocrConfigJson, messages);
|
|
3787
|
+
} catch {
|
|
3788
|
+
return {
|
|
3789
|
+
repointed: [],
|
|
3790
|
+
checked: false
|
|
3791
|
+
};
|
|
3792
|
+
}
|
|
3793
|
+
if (text.trim() === "") return {
|
|
2424
3794
|
repointed: [],
|
|
2425
3795
|
checked: true
|
|
2426
3796
|
};
|
|
2427
3797
|
try {
|
|
2428
3798
|
return {
|
|
2429
3799
|
repointed: await updateConfigLocked(paths.ocrConfigJson, async () => {
|
|
2430
|
-
const doc = parseExistingConfig(
|
|
3800
|
+
const doc = parseExistingConfig(readExistingText(paths.ocrConfigJson, messages), paths.ocrConfigJson, messages);
|
|
2431
3801
|
const result = repointDanglingKeyScripts(doc, current, messages);
|
|
2432
3802
|
if (result.repointed.length === 0) return result.repointed;
|
|
2433
3803
|
await writeConfigAtomic(paths.ocrConfigJson, renderConfigDoc(doc));
|
|
@@ -2435,19 +3805,275 @@ async function ensureKeyScriptPointer(paths, messages) {
|
|
|
2435
3805
|
}),
|
|
2436
3806
|
checked: true
|
|
2437
3807
|
};
|
|
2438
|
-
} catch {
|
|
3808
|
+
} catch {
|
|
3809
|
+
return {
|
|
3810
|
+
repointed: [],
|
|
3811
|
+
checked: false
|
|
3812
|
+
};
|
|
3813
|
+
}
|
|
3814
|
+
}
|
|
3815
|
+
// lib/remote-shape.ts
|
|
3816
|
+
/** 宿主服务键 = 客户端线上命名空间(两侧共用的唯一来源)。 */
|
|
3817
|
+
const OCR_REMOTE_NAMESPACE = "ocrReview";
|
|
3818
|
+
// lib/mcp-config.ts
|
|
3819
|
+
/** 整体上限:MCP 配置是一份人工维护的小表,不承载大文件。 */
|
|
3820
|
+
const MCP_MAX_BYTES = 65536;
|
|
3821
|
+
const MCP_MAX_SERVERS = 100;
|
|
3822
|
+
const MCP_MAX_ARRAY = 100;
|
|
3823
|
+
const MCP_MAX_TEXT = 4096;
|
|
3824
|
+
const KNOWN_FIELDS = /* @__PURE__ */ new Set([
|
|
3825
|
+
"type",
|
|
3826
|
+
"command",
|
|
3827
|
+
"args",
|
|
3828
|
+
"url",
|
|
3829
|
+
"headers",
|
|
3830
|
+
"tools",
|
|
3831
|
+
"setup",
|
|
3832
|
+
"env"
|
|
3833
|
+
]);
|
|
3834
|
+
function failure(name, field, reason, messages) {
|
|
3835
|
+
return {
|
|
3836
|
+
ok: false,
|
|
3837
|
+
error: format(messages.mcpServerInvalid, {
|
|
3838
|
+
name,
|
|
3839
|
+
field,
|
|
3840
|
+
reason
|
|
3841
|
+
})
|
|
3842
|
+
};
|
|
3843
|
+
}
|
|
3844
|
+
function textField(value, name, field, messages) {
|
|
3845
|
+
if (typeof value !== "string" || value.trim() === "") return failure(name, field, "must be a non-empty string", messages);
|
|
3846
|
+
const trimmed = value.trim();
|
|
3847
|
+
if (trimmed.length > MCP_MAX_TEXT) return failure(name, field, `must be at most ${MCP_MAX_TEXT} characters`, messages);
|
|
3848
|
+
return {
|
|
3849
|
+
ok: true,
|
|
3850
|
+
value: trimmed
|
|
3851
|
+
};
|
|
3852
|
+
}
|
|
3853
|
+
function stringArray(value, name, field, messages) {
|
|
3854
|
+
if (!Array.isArray(value) || value.length > MCP_MAX_ARRAY) return failure(name, field, `must be an array of at most ${MCP_MAX_ARRAY} items`, messages);
|
|
3855
|
+
const out = [];
|
|
3856
|
+
for (const item of value) {
|
|
3857
|
+
if (typeof item !== "string" || item.length > MCP_MAX_TEXT) return failure(name, field, `items must be strings of at most ${MCP_MAX_TEXT} characters`, messages);
|
|
3858
|
+
out.push(item);
|
|
3859
|
+
}
|
|
3860
|
+
return {
|
|
3861
|
+
ok: true,
|
|
3862
|
+
value: out
|
|
3863
|
+
};
|
|
3864
|
+
}
|
|
3865
|
+
function headersField(value, name, messages) {
|
|
3866
|
+
if (!isRecord(value) || Object.keys(value).length > 50) return failure(name, "headers", "must be an object of at most 50 string headers", messages);
|
|
3867
|
+
const out = {};
|
|
3868
|
+
for (const [header, raw] of Object.entries(value)) {
|
|
3869
|
+
if (header.trim() === "" || typeof raw !== "string" || raw.length > MCP_MAX_TEXT) return failure(name, `headers.${header}`, "must be a non-empty name and a string value", messages);
|
|
3870
|
+
out[header] = raw;
|
|
3871
|
+
}
|
|
3872
|
+
return {
|
|
3873
|
+
ok: true,
|
|
3874
|
+
value: out
|
|
3875
|
+
};
|
|
3876
|
+
}
|
|
3877
|
+
function normalizeStdio(raw, name, messages) {
|
|
3878
|
+
if (raw["url"] !== void 0 || raw["headers"] !== void 0) return failure(name, "type", "stdio does not accept url/headers", messages);
|
|
3879
|
+
const command = textField(raw["command"], name, "command", messages);
|
|
3880
|
+
if (!command.ok) return command;
|
|
3881
|
+
const entry = {
|
|
3882
|
+
type: "stdio",
|
|
3883
|
+
command: command.value
|
|
3884
|
+
};
|
|
3885
|
+
if (raw["args"] !== void 0) {
|
|
3886
|
+
const args = stringArray(raw["args"], name, "args", messages);
|
|
3887
|
+
if (!args.ok) return args;
|
|
3888
|
+
entry["args"] = args.value;
|
|
3889
|
+
}
|
|
3890
|
+
if (raw["env"] !== void 0) {
|
|
3891
|
+
const env = stringArray(raw["env"], name, "env", messages);
|
|
3892
|
+
if (!env.ok) return env;
|
|
3893
|
+
if (env.value.some((item) => !item.includes("="))) return failure(name, "env", "items must be KEY=VALUE", messages);
|
|
3894
|
+
entry["env"] = env.value;
|
|
3895
|
+
}
|
|
3896
|
+
if (raw["setup"] !== void 0) {
|
|
3897
|
+
const setup = textField(raw["setup"], name, "setup", messages);
|
|
3898
|
+
if (!setup.ok) return setup;
|
|
3899
|
+
entry["setup"] = setup.value;
|
|
3900
|
+
}
|
|
3901
|
+
return {
|
|
3902
|
+
ok: true,
|
|
3903
|
+
value: entry
|
|
3904
|
+
};
|
|
3905
|
+
}
|
|
3906
|
+
function normalizeRemote(raw, name, messages) {
|
|
3907
|
+
if (raw["command"] !== void 0 || raw["args"] !== void 0 || raw["env"] !== void 0 || raw["setup"] !== void 0) return failure(name, "type", "remote does not accept command/args/env/setup", messages);
|
|
3908
|
+
const url = textField(raw["url"], name, "url", messages);
|
|
3909
|
+
if (!url.ok) return url;
|
|
3910
|
+
if (!/^https?:\/\//iu.test(url.value)) return failure(name, "url", "must start with http:// or https://", messages);
|
|
3911
|
+
const entry = {
|
|
3912
|
+
type: "remote",
|
|
3913
|
+
url: url.value
|
|
3914
|
+
};
|
|
3915
|
+
if (raw["headers"] !== void 0) {
|
|
3916
|
+
const headers = headersField(raw["headers"], name, messages);
|
|
3917
|
+
if (!headers.ok) return headers;
|
|
3918
|
+
entry["headers"] = headers.value;
|
|
3919
|
+
}
|
|
3920
|
+
return {
|
|
3921
|
+
ok: true,
|
|
3922
|
+
value: entry
|
|
3923
|
+
};
|
|
3924
|
+
}
|
|
3925
|
+
/** 单个 server 条目 → 只含已知字段的普通对象;未知字段直接拒绝,避免写出 OCR 不认的配置。 */
|
|
3926
|
+
function normalizeServer(name, raw, messages) {
|
|
3927
|
+
if (!isRecord(raw)) return failure(name, "entry", "must be an object", messages);
|
|
3928
|
+
for (const field of Object.keys(raw)) if (!KNOWN_FIELDS.has(field)) return failure(name, field, "is not a supported MCP field", messages);
|
|
3929
|
+
const type = raw["type"] === void 0 ? "stdio" : raw["type"];
|
|
3930
|
+
if (type !== "stdio" && type !== "remote") return failure(name, "type", "must be stdio or remote", messages);
|
|
3931
|
+
const base = type === "stdio" ? normalizeStdio(raw, name, messages) : normalizeRemote(raw, name, messages);
|
|
3932
|
+
if (!base.ok) return base;
|
|
3933
|
+
if (raw["tools"] !== void 0) {
|
|
3934
|
+
const tools = stringArray(raw["tools"], name, "tools", messages);
|
|
3935
|
+
if (!tools.ok) return tools;
|
|
3936
|
+
base.value["tools"] = tools.value;
|
|
3937
|
+
}
|
|
3938
|
+
return base;
|
|
3939
|
+
}
|
|
3940
|
+
function normalizeServers(raw, messages) {
|
|
3941
|
+
if (!isRecord(raw)) return {
|
|
3942
|
+
ok: false,
|
|
3943
|
+
error: messages.mcpShapeInvalid
|
|
3944
|
+
};
|
|
3945
|
+
const names = Object.keys(raw);
|
|
3946
|
+
if (names.length > MCP_MAX_SERVERS) return {
|
|
3947
|
+
ok: false,
|
|
3948
|
+
error: format(messages.mcpTooManyServers, { limit: String(MCP_MAX_SERVERS) })
|
|
3949
|
+
};
|
|
3950
|
+
const out = {};
|
|
3951
|
+
for (const name of names) {
|
|
3952
|
+
if (name.trim() === "" || name.includes("\0") || name.length > 64) return failure(name, "name", "must be a non-empty key of at most 64 characters", messages);
|
|
3953
|
+
const parsed = normalizeServer(name, raw[name], messages);
|
|
3954
|
+
if (!parsed.ok) return parsed;
|
|
3955
|
+
out[name] = parsed.value;
|
|
3956
|
+
}
|
|
3957
|
+
return {
|
|
3958
|
+
ok: true,
|
|
3959
|
+
value: out
|
|
3960
|
+
};
|
|
3961
|
+
}
|
|
3962
|
+
/** 读侧用异步读:卡片端点是 async 面,读大文件时不该占住事件循环。 */
|
|
3963
|
+
function isNotFound(error) {
|
|
3964
|
+
return isRecord(error) && error["code"] === "ENOENT";
|
|
3965
|
+
}
|
|
3966
|
+
/** 读当前 `mcp_servers`,交给卡片编辑;文件不存在按空表处理,坏 JSON/读不动返回可读错误。 */
|
|
3967
|
+
async function readMcpServers(configPath, messages) {
|
|
3968
|
+
let text;
|
|
3969
|
+
try {
|
|
3970
|
+
text = await readFile(configPath, "utf8");
|
|
3971
|
+
} catch (error) {
|
|
3972
|
+
if (!isNotFound(error)) return {
|
|
3973
|
+
ok: false,
|
|
3974
|
+
error: format(messages.mcpReadFailed, {
|
|
3975
|
+
where: configPath,
|
|
3976
|
+
cause: String(error)
|
|
3977
|
+
})
|
|
3978
|
+
};
|
|
3979
|
+
text = "";
|
|
3980
|
+
}
|
|
3981
|
+
if (!text.trim()) return {
|
|
3982
|
+
ok: true,
|
|
3983
|
+
json: "{}"
|
|
3984
|
+
};
|
|
3985
|
+
let doc;
|
|
3986
|
+
try {
|
|
3987
|
+
doc = parseExistingConfig(text, configPath, messages);
|
|
3988
|
+
} catch (error) {
|
|
3989
|
+
return {
|
|
3990
|
+
ok: false,
|
|
3991
|
+
error: format(messages.mcpReadFailed, {
|
|
3992
|
+
where: configPath,
|
|
3993
|
+
cause: String(error)
|
|
3994
|
+
})
|
|
3995
|
+
};
|
|
3996
|
+
}
|
|
3997
|
+
const servers = doc["mcp_servers"];
|
|
3998
|
+
if (servers === void 0 || servers === null) return {
|
|
3999
|
+
ok: true,
|
|
4000
|
+
json: "{}"
|
|
4001
|
+
};
|
|
4002
|
+
if (!isRecord(servers)) return {
|
|
4003
|
+
ok: false,
|
|
4004
|
+
error: messages.mcpShapeInvalid
|
|
4005
|
+
};
|
|
4006
|
+
return {
|
|
4007
|
+
ok: true,
|
|
4008
|
+
json: JSON.stringify(servers, null, 2)
|
|
4009
|
+
};
|
|
4010
|
+
}
|
|
4011
|
+
/** 校验并写回 `mcp_servers`;只改这一支,其余配置原样保留。 */
|
|
4012
|
+
async function saveMcpServers(configPath, json, messages) {
|
|
4013
|
+
if (Buffer.byteLength(json, "utf8") > MCP_MAX_BYTES) return {
|
|
4014
|
+
ok: false,
|
|
4015
|
+
error: format(messages.mcpJsonTooLarge, { limit: String(MCP_MAX_BYTES) })
|
|
4016
|
+
};
|
|
4017
|
+
let raw;
|
|
4018
|
+
try {
|
|
4019
|
+
raw = JSON.parse(json);
|
|
4020
|
+
} catch (error) {
|
|
4021
|
+
return {
|
|
4022
|
+
ok: false,
|
|
4023
|
+
error: format(messages.mcpJsonInvalid, { cause: String(error) })
|
|
4024
|
+
};
|
|
4025
|
+
}
|
|
4026
|
+
const normalized = normalizeServers(raw, messages);
|
|
4027
|
+
if (!normalized.ok) return normalized;
|
|
4028
|
+
try {
|
|
4029
|
+
return {
|
|
4030
|
+
ok: true,
|
|
4031
|
+
count: await updateConfigLocked(configPath, async () => {
|
|
4032
|
+
const doc = parseExistingConfig(readExistingText(configPath, messages), configPath, messages);
|
|
4033
|
+
doc["mcp_servers"] = normalized.value;
|
|
4034
|
+
await writeConfigAtomic(configPath, `${JSON.stringify(doc, null, 2)}\n`);
|
|
4035
|
+
return Object.keys(normalized.value).length;
|
|
4036
|
+
})
|
|
4037
|
+
};
|
|
4038
|
+
} catch (error) {
|
|
4039
|
+
return {
|
|
4040
|
+
ok: false,
|
|
4041
|
+
error: format(messages.mcpReadFailed, {
|
|
4042
|
+
where: configPath,
|
|
4043
|
+
cause: String(error)
|
|
4044
|
+
})
|
|
4045
|
+
};
|
|
4046
|
+
}
|
|
4047
|
+
}
|
|
4048
|
+
/** ChatGPT provider 的配置写入:不写 key,只设 provider 与模型。 */
|
|
4049
|
+
async function applyChatGptModel(configPath, model, messages) {
|
|
4050
|
+
const trimmed = model.trim();
|
|
4051
|
+
if (trimmed === "" || trimmed.length > 256 || trimmed.includes("\0")) return {
|
|
4052
|
+
ok: false,
|
|
4053
|
+
error: messages.chatGptModelRequired
|
|
4054
|
+
};
|
|
4055
|
+
try {
|
|
4056
|
+
await updateConfigLocked(configPath, async () => {
|
|
4057
|
+
const doc = parseExistingConfig(readExistingText(configPath, messages), configPath, messages);
|
|
4058
|
+
const providers = isRecord(doc["providers"]) ? doc["providers"] : {};
|
|
4059
|
+
const entry = isRecord(providers["chatgpt"]) ? providers["chatgpt"] : {};
|
|
4060
|
+
delete entry["api_key"];
|
|
4061
|
+
delete entry["api_key_cmd"];
|
|
4062
|
+
entry["model"] = trimmed;
|
|
4063
|
+
providers["chatgpt"] = entry;
|
|
4064
|
+
doc["providers"] = providers;
|
|
4065
|
+
doc["provider"] = "chatgpt";
|
|
4066
|
+
await writeConfigAtomic(configPath, `${JSON.stringify(doc, null, 2)}\n`);
|
|
4067
|
+
});
|
|
4068
|
+
return { ok: true };
|
|
4069
|
+
} catch (error) {
|
|
2439
4070
|
return {
|
|
2440
|
-
|
|
2441
|
-
|
|
4071
|
+
ok: false,
|
|
4072
|
+
error: format(messages.chatGptSaveFailed, { cause: String(error) })
|
|
2442
4073
|
};
|
|
2443
4074
|
}
|
|
2444
4075
|
}
|
|
2445
|
-
|
|
2446
|
-
//#region lib/remote-shape.ts
|
|
2447
|
-
/** 宿主服务键 = 客户端线上命名空间(两侧共用的唯一来源)。 */
|
|
2448
|
-
const OCR_REMOTE_NAMESPACE = "ocrReview";
|
|
2449
|
-
//#endregion
|
|
2450
|
-
//#region host.ts
|
|
4076
|
+
// host.ts
|
|
2451
4077
|
const PLUGIN_DIR = import.meta.dirname;
|
|
2452
4078
|
const MAX_TIMEOUT_REQUEST_MS = 2147483647;
|
|
2453
4079
|
const LLM_TEST_TIMEOUT_MS = 9e4;
|
|
@@ -2457,6 +4083,10 @@ const LLM_TEST_TIMEOUT_MS = 9e4;
|
|
|
2457
4083
|
* 人按下「检测」时跑,不进任何热路径,因此不做成设置项。 */
|
|
2458
4084
|
const VERSION_PROBE_TIMEOUT_MS = 15e3;
|
|
2459
4085
|
const STDOUT_MAX_BYTES = 4e5;
|
|
4086
|
+
/** `--output` 落盘结果的读取上限(字节)。**刻意不取 STDOUT_MAX_BYTES**:`--output` 存在的
|
|
4087
|
+
* 理由正是「结果比 stdout 缓冲大」,用 400 KB 卡它等于把正常的大仓评审判死。这一枚只是
|
|
4088
|
+
* 护栏,把「整份读进内存」的失败形态从 OOM 换成一条点名文件的错误。 */
|
|
4089
|
+
const OUT_FILE_MAX_BYTES = 67108864;
|
|
2460
4090
|
/** 解析失败时回显给模型的原始内容长度上限(UTF-16 码元;已脱敏)。
|
|
2461
4091
|
* 单位写码元而不是"字符":本常量喂给 `echoRaw`→`truncateEnd`,那一刀按码元数计
|
|
2462
4092
|
* (一张 emoji = 2 枚),写"字符"会把给模型的说明夸大约一倍。 */
|
|
@@ -2541,377 +4171,73 @@ async function enforceOcrDeadline(jobs, id, reason, backgroundMaxMs) {
|
|
|
2541
4171
|
*/
|
|
2542
4172
|
function reclaimOcrJobs(jobs, reason) {
|
|
2543
4173
|
const mine = jobs.list().filter((job) => job.kind === OCR_JOB_KIND);
|
|
2544
|
-
for (const job of mine) if (isLiveOcrStatus(job.status)) jobs.kill(job.id, void 0, reason);
|
|
2545
|
-
else jobs.remove(job.id);
|
|
2546
|
-
}
|
|
2547
|
-
/** 当前会话工作区(repo 缺省回退源);非字符串视为缺。
|
|
2548
|
-
* 官方形状:`agent?: Agent` → `session: Session` → `header: SessionHeader` →
|
|
2549
|
-
* `cwd?: string`。下面的逐层 `?.` 与 `typeof` 复核是跨进程契约的运行时闸:宿主半装配
|
|
2550
|
-
* 递来的 agent 可能是 `null`、也可能没有 `session`/`header`(test/host.test.ts 钉住
|
|
2551
|
-
* 这两条 fail-closed 路径),值一律经 `unknown` 读,绝不只信声明。 */
|
|
2552
|
-
function sessionHeaderCwd(exec) {
|
|
2553
|
-
const raw = exec.agent?.session?.header?.cwd;
|
|
2554
|
-
return typeof raw === "string" && raw.length > 0 ? raw : void 0;
|
|
2555
|
-
}
|
|
2556
|
-
/** host 是否为本插件所需服务面(settings/shell/tools/systemPrompt/get/inject 及
|
|
2557
|
-
* 其上要调用的方法)。守卫仅压缩类型面、避开 `as unknown as HostCtx` 断言。
|
|
2558
|
-
* settings 这里点名的两个方法都会被调用:configure 登记本页的页面策略、describe
|
|
2559
|
-
* 跨命名空间读(provider 清单 + 官方 locale 偏好);inject 挂那条页面策略的 effect。
|
|
2560
|
-
* settings 面没有 register/get,多点名一个就是永不成立的死分支。 */
|
|
2561
|
-
function isOcrReviewHost(value) {
|
|
2562
|
-
if (!isRecord(value)) return false;
|
|
2563
|
-
const { settings, shell, tools, systemPrompt, inject, get } = value;
|
|
2564
|
-
return hasMethods(settings, ["configure", "describe"]) && hasMethods(shell, ["resolve", "execute"]) && hasMethods(tools, ["register"]) && hasMethods(systemPrompt, ["section"]) && typeof inject === "function" && typeof get === "function";
|
|
2565
|
-
}
|
|
2566
|
-
/** 从插件 Context 解析宿主服务面(守卫收窄,不做 `as unknown as` 断言)。 */
|
|
2567
|
-
function resolveHost(ctx) {
|
|
2568
|
-
if (!isOcrReviewHost(ctx)) throw new Error("ocr-review: 宿主缺少所需的 shell/tools/settings/systemPrompt/inject 服务界面(或其方法)");
|
|
2569
|
-
return ctx;
|
|
2570
|
-
}
|
|
2571
|
-
/** describe() 行里按命名空间取解析值(跨命名空间读只有 describe() 一条路:
|
|
2572
|
-
* `settings.get(ns)` 已删)。未注册该命名空间 → undefined,调用侧按默认值降级。
|
|
2573
|
-
* @param rows - `ctx.settings.describe()` 的返回(全部条目的表单投影)。
|
|
2574
|
-
* @param ns - 要读的命名空间(官方字段名是 `ns`)。
|
|
2575
|
-
*/
|
|
2576
|
-
function describedValue(rows, ns) {
|
|
2577
|
-
return rows.find((row) => row.ns === ns)?.value;
|
|
2578
|
-
}
|
|
2579
|
-
/**
|
|
2580
|
-
* 取本包 host 侧文案(按官方 locale 偏好现取现用):设置页「常规」里换语言后,
|
|
2581
|
-
* 下一次工具调用/端点请求即换新文案,不需要重启或重装插件。
|
|
2582
|
-
* locale 命名空间未注册(无官方 client-locale 包)→ describe() 里没有这条 →
|
|
2583
|
-
* undefined → 中文默认。
|
|
2584
|
-
*/
|
|
2585
|
-
function localeMessages(host) {
|
|
2586
|
-
const preference = describedValue(host.settings.describe(), LOCALE_SETTINGS_NAMESPACE);
|
|
2587
|
-
return messagesFor(MESSAGES, resolveLocalePreference(preference));
|
|
2588
|
-
}
|
|
2589
|
-
/**
|
|
2590
|
-
* render 只做 model-facing 展示:把 canonical value 本身 pretty-print 成文本块。
|
|
2591
|
-
* execute 直返结构化对象(不再 stringify 进 `{ text }`),所以这里 stringify 的是
|
|
2592
|
-
* 第一层也是唯一一层 JSON;循环结构/不可序列化值的两级兜底是防御,不是常规路径。
|
|
2593
|
-
*/
|
|
2594
|
-
function textBlock(value) {
|
|
2595
|
-
let text;
|
|
2596
|
-
try {
|
|
2597
|
-
const json = JSON.stringify(value, null, 2);
|
|
2598
|
-
text = typeof json === "string" ? json : String(value);
|
|
2599
|
-
} catch {
|
|
2600
|
-
try {
|
|
2601
|
-
text = String(value);
|
|
2602
|
-
} catch {
|
|
2603
|
-
text = "[unrenderable value]";
|
|
2604
|
-
}
|
|
2605
|
-
}
|
|
2606
|
-
return [{
|
|
2607
|
-
type: "text",
|
|
2608
|
-
text
|
|
2609
|
-
}];
|
|
2610
|
-
}
|
|
2611
|
-
/** 可空 string 字段的 schema 片段(官方子集单类型,null 分支用 oneOf 表达)。 */
|
|
2612
|
-
const NULLABLE_STRING = { oneOf: [{ type: "string" }, { type: "null" }] };
|
|
2613
|
-
/** ocr_review / ocr_scan 的输出:wait=false = 后台回执对象;前台成功 = 摘要对象;
|
|
2614
|
-
* 前台解析失败 = 一段诚实的说明字符串。三者恰一(required 逐字段声明保证两个
|
|
2615
|
-
* 对象分支互斥)。 */
|
|
2616
|
-
const REVIEW_OUTPUT_SCHEMA = { oneOf: [
|
|
2617
|
-
{
|
|
2618
|
-
type: "object",
|
|
2619
|
-
additionalProperties: false,
|
|
2620
|
-
required: [
|
|
2621
|
-
"started",
|
|
2622
|
-
"mode",
|
|
2623
|
-
"tool",
|
|
2624
|
-
"repo",
|
|
2625
|
-
"processStatus",
|
|
2626
|
-
"jobId",
|
|
2627
|
-
"polling"
|
|
2628
|
-
],
|
|
2629
|
-
properties: {
|
|
2630
|
-
started: {
|
|
2631
|
-
type: "boolean",
|
|
2632
|
-
const: true
|
|
2633
|
-
},
|
|
2634
|
-
mode: {
|
|
2635
|
-
type: "string",
|
|
2636
|
-
const: "background"
|
|
2637
|
-
},
|
|
2638
|
-
tool: { type: "string" },
|
|
2639
|
-
repo: { type: "string" },
|
|
2640
|
-
processStatus: {
|
|
2641
|
-
type: "string",
|
|
2642
|
-
enum: [
|
|
2643
|
-
"running",
|
|
2644
|
-
"completed",
|
|
2645
|
-
"killed"
|
|
2646
|
-
]
|
|
2647
|
-
},
|
|
2648
|
-
jobId: { type: "string" },
|
|
2649
|
-
polling: { type: "string" }
|
|
2650
|
-
}
|
|
2651
|
-
},
|
|
2652
|
-
{
|
|
2653
|
-
type: "object",
|
|
2654
|
-
additionalProperties: false,
|
|
2655
|
-
required: [
|
|
2656
|
-
"status",
|
|
2657
|
-
"skipped",
|
|
2658
|
-
"message",
|
|
2659
|
-
"summary",
|
|
2660
|
-
"budgetExceeded",
|
|
2661
|
-
"sessionId",
|
|
2662
|
-
"aggregation",
|
|
2663
|
-
"totalCommentCount",
|
|
2664
|
-
"droppedCount",
|
|
2665
|
-
"invalidCommentCount",
|
|
2666
|
-
"topComments",
|
|
2667
|
-
"warnings",
|
|
2668
|
-
"toolCalls",
|
|
2669
|
-
"coverage",
|
|
2670
|
-
"hint"
|
|
2671
|
-
],
|
|
2672
|
-
properties: {
|
|
2673
|
-
status: {
|
|
2674
|
-
type: "string",
|
|
2675
|
-
enum: [
|
|
2676
|
-
"success",
|
|
2677
|
-
"complete",
|
|
2678
|
-
"partial",
|
|
2679
|
-
"completed_with_warnings",
|
|
2680
|
-
"completed_with_errors",
|
|
2681
|
-
"skipped"
|
|
2682
|
-
]
|
|
2683
|
-
},
|
|
2684
|
-
skipped: { type: "boolean" },
|
|
2685
|
-
message: NULLABLE_STRING,
|
|
2686
|
-
summary: { oneOf: [{ type: "null" }, {
|
|
2687
|
-
type: "object",
|
|
2688
|
-
additionalProperties: false,
|
|
2689
|
-
required: [
|
|
2690
|
-
"filesReviewed",
|
|
2691
|
-
"comments",
|
|
2692
|
-
"totalTokens",
|
|
2693
|
-
"inputTokens",
|
|
2694
|
-
"outputTokens",
|
|
2695
|
-
"elapsed"
|
|
2696
|
-
],
|
|
2697
|
-
properties: {
|
|
2698
|
-
filesReviewed: { type: "number" },
|
|
2699
|
-
comments: { type: "number" },
|
|
2700
|
-
totalTokens: { type: "number" },
|
|
2701
|
-
inputTokens: { type: "number" },
|
|
2702
|
-
outputTokens: { type: "number" },
|
|
2703
|
-
elapsed: { type: "string" }
|
|
2704
|
-
}
|
|
2705
|
-
}] },
|
|
2706
|
-
budgetExceeded: { type: "boolean" },
|
|
2707
|
-
sessionId: NULLABLE_STRING,
|
|
2708
|
-
aggregation: {
|
|
2709
|
-
type: "object",
|
|
2710
|
-
additionalProperties: false,
|
|
2711
|
-
required: [
|
|
2712
|
-
"total",
|
|
2713
|
-
"bySeverity",
|
|
2714
|
-
"byCategory",
|
|
2715
|
-
"unknownSeverityCount"
|
|
2716
|
-
],
|
|
2717
|
-
properties: {
|
|
2718
|
-
total: { type: "integer" },
|
|
2719
|
-
unknownSeverityCount: { type: "integer" },
|
|
2720
|
-
bySeverity: {
|
|
2721
|
-
type: "object",
|
|
2722
|
-
additionalProperties: false,
|
|
2723
|
-
required: [
|
|
2724
|
-
"critical",
|
|
2725
|
-
"high",
|
|
2726
|
-
"medium",
|
|
2727
|
-
"low"
|
|
2728
|
-
],
|
|
2729
|
-
properties: {
|
|
2730
|
-
critical: { type: "integer" },
|
|
2731
|
-
high: { type: "integer" },
|
|
2732
|
-
medium: { type: "integer" },
|
|
2733
|
-
low: { type: "integer" }
|
|
2734
|
-
}
|
|
2735
|
-
},
|
|
2736
|
-
byCategory: { type: "object" }
|
|
2737
|
-
}
|
|
2738
|
-
},
|
|
2739
|
-
totalCommentCount: { type: "integer" },
|
|
2740
|
-
droppedCount: { type: "integer" },
|
|
2741
|
-
invalidCommentCount: { type: "integer" },
|
|
2742
|
-
topComments: {
|
|
2743
|
-
type: "array",
|
|
2744
|
-
items: {
|
|
2745
|
-
type: "object",
|
|
2746
|
-
additionalProperties: false,
|
|
2747
|
-
required: [
|
|
2748
|
-
"location",
|
|
2749
|
-
"severity",
|
|
2750
|
-
"category",
|
|
2751
|
-
"content",
|
|
2752
|
-
"suggestion"
|
|
2753
|
-
],
|
|
2754
|
-
properties: {
|
|
2755
|
-
location: { type: "string" },
|
|
2756
|
-
severity: { type: "string" },
|
|
2757
|
-
category: { type: "string" },
|
|
2758
|
-
content: { type: "string" },
|
|
2759
|
-
suggestion: NULLABLE_STRING
|
|
2760
|
-
}
|
|
2761
|
-
}
|
|
2762
|
-
},
|
|
2763
|
-
warnings: {
|
|
2764
|
-
type: "array",
|
|
2765
|
-
items: { type: "string" }
|
|
2766
|
-
},
|
|
2767
|
-
toolCalls: { oneOf: [{ type: "null" }, {
|
|
2768
|
-
type: "object",
|
|
2769
|
-
additionalProperties: false,
|
|
2770
|
-
required: [
|
|
2771
|
-
"total",
|
|
2772
|
-
"failure",
|
|
2773
|
-
"failures"
|
|
2774
|
-
],
|
|
2775
|
-
properties: {
|
|
2776
|
-
total: { type: "integer" },
|
|
2777
|
-
failure: { type: "integer" },
|
|
2778
|
-
failures: {
|
|
2779
|
-
type: "array",
|
|
2780
|
-
items: { type: "string" }
|
|
2781
|
-
}
|
|
2782
|
-
}
|
|
2783
|
-
}] },
|
|
2784
|
-
coverage: { oneOf: [{ type: "null" }, {
|
|
2785
|
-
type: "object",
|
|
2786
|
-
additionalProperties: false,
|
|
2787
|
-
required: [
|
|
2788
|
-
"selected",
|
|
2789
|
-
"completed",
|
|
2790
|
-
"reused",
|
|
2791
|
-
"failed",
|
|
2792
|
-
"waived",
|
|
2793
|
-
"failedItems"
|
|
2794
|
-
],
|
|
2795
|
-
properties: {
|
|
2796
|
-
selected: { type: "integer" },
|
|
2797
|
-
completed: { type: "integer" },
|
|
2798
|
-
reused: { type: "integer" },
|
|
2799
|
-
failed: { type: "integer" },
|
|
2800
|
-
waived: { type: "integer" },
|
|
2801
|
-
failedItems: {
|
|
2802
|
-
type: "array",
|
|
2803
|
-
items: { type: "string" }
|
|
2804
|
-
}
|
|
2805
|
-
}
|
|
2806
|
-
}] },
|
|
2807
|
-
hint: { type: "string" }
|
|
2808
|
-
}
|
|
2809
|
-
},
|
|
2810
|
-
{ type: "string" }
|
|
2811
|
-
] };
|
|
2812
|
-
/** ocr_delegate_preview:成功 = 清单对象;解析失败 = 说明字符串。 */
|
|
2813
|
-
const PREVIEW_OUTPUT_SCHEMA = { oneOf: [{
|
|
2814
|
-
type: "object",
|
|
2815
|
-
additionalProperties: false,
|
|
2816
|
-
required: [
|
|
2817
|
-
"mode",
|
|
2818
|
-
"repository",
|
|
2819
|
-
"mergeBase",
|
|
2820
|
-
"reviewableCount",
|
|
2821
|
-
"insertions",
|
|
2822
|
-
"deletions",
|
|
2823
|
-
"reviewableFiles",
|
|
2824
|
-
"excludedFiles",
|
|
2825
|
-
"hint"
|
|
2826
|
-
],
|
|
2827
|
-
properties: {
|
|
2828
|
-
mode: { type: "string" },
|
|
2829
|
-
repository: { type: "string" },
|
|
2830
|
-
mergeBase: NULLABLE_STRING,
|
|
2831
|
-
reviewableCount: { type: "integer" },
|
|
2832
|
-
insertions: { type: "number" },
|
|
2833
|
-
deletions: { type: "number" },
|
|
2834
|
-
reviewableFiles: {
|
|
2835
|
-
type: "array",
|
|
2836
|
-
items: {
|
|
2837
|
-
type: "object",
|
|
2838
|
-
additionalProperties: false,
|
|
2839
|
-
required: [
|
|
2840
|
-
"path",
|
|
2841
|
-
"status",
|
|
2842
|
-
"insertions",
|
|
2843
|
-
"deletions"
|
|
2844
|
-
],
|
|
2845
|
-
properties: {
|
|
2846
|
-
path: { type: "string" },
|
|
2847
|
-
status: { type: "string" },
|
|
2848
|
-
insertions: { type: "number" },
|
|
2849
|
-
deletions: { type: "number" }
|
|
2850
|
-
}
|
|
2851
|
-
}
|
|
2852
|
-
},
|
|
2853
|
-
excludedFiles: {
|
|
2854
|
-
type: "array",
|
|
2855
|
-
items: {
|
|
2856
|
-
type: "object",
|
|
2857
|
-
additionalProperties: false,
|
|
2858
|
-
required: ["path", "reason"],
|
|
2859
|
-
properties: {
|
|
2860
|
-
path: { type: "string" },
|
|
2861
|
-
reason: { type: "string" }
|
|
2862
|
-
}
|
|
2863
|
-
}
|
|
2864
|
-
},
|
|
2865
|
-
hint: { type: "string" }
|
|
2866
|
-
}
|
|
2867
|
-
}, { type: "string" }] };
|
|
2868
|
-
/** ocr_delegate_rule:成功 = 分组对象;解析失败 = 说明字符串。 */
|
|
2869
|
-
const RULES_OUTPUT_SCHEMA = { oneOf: [{
|
|
2870
|
-
type: "object",
|
|
2871
|
-
additionalProperties: false,
|
|
2872
|
-
required: [
|
|
2873
|
-
"groupCount",
|
|
2874
|
-
"groups",
|
|
2875
|
-
"hint"
|
|
2876
|
-
],
|
|
2877
|
-
properties: {
|
|
2878
|
-
groupCount: { type: "integer" },
|
|
2879
|
-
groups: {
|
|
2880
|
-
type: "array",
|
|
2881
|
-
items: {
|
|
2882
|
-
type: "object",
|
|
2883
|
-
additionalProperties: false,
|
|
2884
|
-
required: [
|
|
2885
|
-
"source",
|
|
2886
|
-
"pattern",
|
|
2887
|
-
"files",
|
|
2888
|
-
"rule"
|
|
2889
|
-
],
|
|
2890
|
-
properties: {
|
|
2891
|
-
source: { type: "string" },
|
|
2892
|
-
pattern: { type: "string" },
|
|
2893
|
-
files: {
|
|
2894
|
-
type: "array",
|
|
2895
|
-
items: { type: "string" }
|
|
2896
|
-
},
|
|
2897
|
-
rule: { type: "string" }
|
|
2898
|
-
}
|
|
2899
|
-
}
|
|
2900
|
-
},
|
|
2901
|
-
hint: { type: "string" }
|
|
2902
|
-
}
|
|
2903
|
-
}, { type: "string" }] };
|
|
2904
|
-
/** ocr_session:sessionOutput 本身就是一手透传的字符串(诚实的值),外层仍是对象
|
|
2905
|
-
* 好让 hint 有处安放。 */
|
|
2906
|
-
const SESSION_OUTPUT_SCHEMA = {
|
|
2907
|
-
type: "object",
|
|
2908
|
-
additionalProperties: false,
|
|
2909
|
-
required: ["sessionOutput", "hint"],
|
|
2910
|
-
properties: {
|
|
2911
|
-
sessionOutput: { type: "string" },
|
|
2912
|
-
hint: { type: "string" }
|
|
4174
|
+
for (const job of mine) if (isLiveOcrStatus(job.status)) jobs.kill(job.id, void 0, reason);
|
|
4175
|
+
else jobs.remove(job.id);
|
|
4176
|
+
}
|
|
4177
|
+
/** 当前会话工作区(repo 缺省回退源);非字符串视为缺。
|
|
4178
|
+
* 官方形状:`agent?: Agent` → `session: Session` → `header: SessionHeader` →
|
|
4179
|
+
* `cwd?: string`。下面的逐层 `?.` 与 `typeof` 复核是跨进程契约的运行时闸:宿主半装配
|
|
4180
|
+
* 递来的 agent 可能是 `null`、也可能没有 `session`/`header`(test/host.test.ts 钉住
|
|
4181
|
+
* 这两条 fail-closed 路径),值一律经 `unknown` 读,绝不只信声明。 */
|
|
4182
|
+
function sessionHeaderCwd(exec) {
|
|
4183
|
+
const raw = exec.agent?.session?.header?.cwd;
|
|
4184
|
+
return typeof raw === "string" && raw.length > 0 ? raw : void 0;
|
|
4185
|
+
}
|
|
4186
|
+
/** host 是否为本插件所需服务面(settings/shell/tools/systemPrompt/get/inject 及
|
|
4187
|
+
* 其上要调用的方法)。守卫仅压缩类型面、避开 `as unknown as HostCtx` 断言。
|
|
4188
|
+
* settings 这里点名的两个方法都会被调用:configure 登记本页的页面策略、describe
|
|
4189
|
+
* 跨命名空间读(provider 清单 + 官方 locale 偏好);inject 挂那条页面策略的 effect。
|
|
4190
|
+
* settings 面没有 register/get,多点名一个就是永不成立的死分支。 */
|
|
4191
|
+
function isOcrReviewHost(value) {
|
|
4192
|
+
if (!isRecord(value)) return false;
|
|
4193
|
+
const { settings, shell, tools, systemPrompt, inject, get } = value;
|
|
4194
|
+
return hasMethods(settings, ["configure", "describe"]) && hasMethods(shell, ["resolve", "execute"]) && hasMethods(tools, ["register"]) && hasMethods(systemPrompt, ["section"]) && typeof inject === "function" && typeof get === "function";
|
|
4195
|
+
}
|
|
4196
|
+
/** 从插件 Context 解析宿主服务面(守卫收窄,不做 `as unknown as` 断言)。 */
|
|
4197
|
+
function resolveHost(ctx) {
|
|
4198
|
+
if (!isOcrReviewHost(ctx)) throw new Error("ocr-review: 宿主缺少所需的 shell/tools/settings/systemPrompt/inject 服务界面(或其方法)");
|
|
4199
|
+
return ctx;
|
|
4200
|
+
}
|
|
4201
|
+
/** describe() 行里按命名空间取解析值(跨命名空间读只有 describe() 一条路:
|
|
4202
|
+
* `settings.get(ns)` 已删)。未注册该命名空间 → undefined,调用侧按默认值降级。
|
|
4203
|
+
* @param rows - `ctx.settings.describe()` 的返回(全部条目的表单投影)。
|
|
4204
|
+
* @param ns - 要读的命名空间(官方字段名是 `ns`)。
|
|
4205
|
+
*/
|
|
4206
|
+
function describedValue(rows, ns) {
|
|
4207
|
+
return rows.find((row) => row.ns === ns)?.value;
|
|
4208
|
+
}
|
|
4209
|
+
/**
|
|
4210
|
+
* 取本包 host 侧文案(按官方 locale 偏好现取现用):设置页「常规」里换语言后,
|
|
4211
|
+
* 下一次工具调用/端点请求即换新文案,不需要重启或重装插件。
|
|
4212
|
+
* locale 命名空间未注册(无官方 client-locale 包)→ describe() 里没有这条 →
|
|
4213
|
+
* undefined → 中文默认。
|
|
4214
|
+
*/
|
|
4215
|
+
function localeMessages(host) {
|
|
4216
|
+
const preference = describedValue(host.settings.describe(), LOCALE_SETTINGS_NAMESPACE);
|
|
4217
|
+
return messagesFor(MESSAGES, resolveLocalePreference(preference));
|
|
4218
|
+
}
|
|
4219
|
+
/**
|
|
4220
|
+
* render 只做 model-facing 展示:把 canonical value 本身 pretty-print 成文本块。
|
|
4221
|
+
* execute 直返结构化对象(不再 stringify 进 `{ text }`),所以这里 stringify 的是
|
|
4222
|
+
* 第一层也是唯一一层 JSON;循环结构/不可序列化值的两级兜底是防御,不是常规路径。
|
|
4223
|
+
*/
|
|
4224
|
+
function textBlock(value) {
|
|
4225
|
+
let text;
|
|
4226
|
+
try {
|
|
4227
|
+
const json = JSON.stringify(value, null, 2);
|
|
4228
|
+
text = typeof json === "string" ? json : String(value);
|
|
4229
|
+
} catch {
|
|
4230
|
+
try {
|
|
4231
|
+
text = String(value);
|
|
4232
|
+
} catch {
|
|
4233
|
+
text = "[unrenderable value]";
|
|
4234
|
+
}
|
|
2913
4235
|
}
|
|
2914
|
-
|
|
4236
|
+
return [{
|
|
4237
|
+
type: "text",
|
|
4238
|
+
text
|
|
4239
|
+
}];
|
|
4240
|
+
}
|
|
2915
4241
|
/**
|
|
2916
4242
|
* ocr 子进程一律按 `danger-full-access` 请求(官方按调用携带的 `SandboxExecutionPolicy`)。
|
|
2917
4243
|
*
|
|
@@ -2930,7 +4256,7 @@ const SESSION_OUTPUT_SCHEMA = {
|
|
|
2930
4256
|
* 路径、没有自定义 HOME、没有目录级豁免;而 `SandboxMode` 的取值只有
|
|
2931
4257
|
* `read-only | workspace-write | danger-full-access` 三档,中间那一档的可写根就是
|
|
2932
4258
|
* `workspaceRoot` 一枚。两处声明都取自本包实际解析到的那版(dsh-sandbox-policy 与
|
|
2933
|
-
* dsh-sandbox 的 0.2.1-rc.
|
|
4259
|
+
* dsh-sandbox 的 0.2.1-rc.3,`lib/types/index.d.ts`)。
|
|
2934
4260
|
* 可选的收紧路径只有「改 ocr 自己的状态落点」或「等上游加字段」,两者都不是本包能在
|
|
2935
4261
|
* 一次补丁里做完的事。故此处维持放开,但把边界写明:放开的是 ocr 这一个子进程的整棵
|
|
2936
4262
|
* 文件系统写权限,不是宿主其它能力;且 ocr 读什么、写什么仍由上面那条 argv 闸与
|
|
@@ -3136,7 +4462,7 @@ async function runCollect(host, command, workdir, run) {
|
|
|
3136
4462
|
}
|
|
3137
4463
|
function readEffort(config) {
|
|
3138
4464
|
const raw = config.effort.get();
|
|
3139
|
-
return raw === "low" || raw === "high" ? raw : "medium";
|
|
4465
|
+
return raw === "auto" || raw === "low" || raw === "high" ? raw : "medium";
|
|
3140
4466
|
}
|
|
3141
4467
|
function readLanguage(config) {
|
|
3142
4468
|
const lang = config.language.get();
|
|
@@ -3232,8 +4558,20 @@ function echoRaw(text, limit) {
|
|
|
3232
4558
|
return redactKeyMaterial(truncateEnd(text, limit));
|
|
3233
4559
|
}
|
|
3234
4560
|
/** 读 --output 落盘结果。OCR exit 0 却没写出文件(版本漂移/被中途 kill)时给
|
|
3235
|
-
* 可行动的错误,而不是把裸 ENOENT 栈丢给模型。
|
|
4561
|
+
* 可行动的错误,而不是把裸 ENOENT 栈丢给模型。
|
|
4562
|
+
* 超过 OUT_FILE_MAX_BYTES 的先拒掉:`readFileSync` + `JSON.parse` 是两倍内存的整份读,
|
|
4563
|
+
* 病理级的大回执只该换回一条点名文件的错误。大小探测失败(文件不在)不在这里报——
|
|
4564
|
+
* 交给下面那条 `outputFileMissing` 统一说话。 */
|
|
3236
4565
|
function readOutFile(outPath, messages) {
|
|
4566
|
+
let size = 0;
|
|
4567
|
+
try {
|
|
4568
|
+
({size} = statSync(outPath));
|
|
4569
|
+
} catch {}
|
|
4570
|
+
if (size > OUT_FILE_MAX_BYTES) throw new Error(format(messages.outputFileTooLarge, {
|
|
4571
|
+
path: outPath,
|
|
4572
|
+
size: String(size),
|
|
4573
|
+
limit: String(OUT_FILE_MAX_BYTES)
|
|
4574
|
+
}));
|
|
3237
4575
|
try {
|
|
3238
4576
|
return readFileSync(outPath, "utf8");
|
|
3239
4577
|
} catch (error) {
|
|
@@ -3244,11 +4582,35 @@ function readOutFile(outPath, messages) {
|
|
|
3244
4582
|
}
|
|
3245
4583
|
}
|
|
3246
4584
|
/**
|
|
4585
|
+
* 非零退出时把 `--output` 里已经发布的 coverage.failed 明细附到失败详情上。
|
|
4586
|
+
*
|
|
4587
|
+
* 上游的退出契约只对 run-level 失败、或「每个被选中的文件都失败」给非零,而那份 manifest
|
|
4588
|
+
* 照发(`review_cmd.go` 的 reviewResultError:“Partial results are published regardless”)
|
|
4589
|
+
* ——只回 stderr 的话模型读到「N of M selected item(s) failed」,却不知道是哪些文件、
|
|
4590
|
+
* 哪一类失败。读不出或没有失败明细时原样交回那个错误,不改变既有失败形态。
|
|
4591
|
+
*
|
|
4592
|
+
* 这里刻意不复用 `readOutFile`:那条路会给「文件缺失/过大」抛错,而失败详情里再加一层
|
|
4593
|
+
* 「读不到落盘结果」的错误只会盖住 OCR 自己的报错。
|
|
4594
|
+
*/
|
|
4595
|
+
function withCoverageDetail(error, outPath, messages) {
|
|
4596
|
+
let text;
|
|
4597
|
+
try {
|
|
4598
|
+
text = readFileSync(outPath, "utf8");
|
|
4599
|
+
} catch {
|
|
4600
|
+
return error;
|
|
4601
|
+
}
|
|
4602
|
+
const items = parseReviewOutput(text, messages).coverage?.failedItems ?? [];
|
|
4603
|
+
if (items.length === 0) return error;
|
|
4604
|
+
const note = format(messages.failureCoverage, { items: redactKeyMaterial(items.join("; ")) });
|
|
4605
|
+
return new Error(`${errorText(error)}${note}`, { cause: error });
|
|
4606
|
+
}
|
|
4607
|
+
/**
|
|
3247
4608
|
* OCR 原生数值参数的 tool schema 片段(忠实透传语义,桥接层不设钳制)。
|
|
3248
|
-
* unit 区分 review(每组)/ scan
|
|
3249
|
-
*
|
|
4609
|
+
* unit 区分 review(每组)/ scan(每文件)的措辞;maxToolsTemplate 也按命令分开,因为
|
|
4610
|
+
* 两侧的 --max-tools 语义本就不同:review 把 1-49 抬到 50,scan 只在大于模板默认时生效。
|
|
4611
|
+
* 语言随设置页偏好走;OCR 侧语义见 shared_flags.go 的 registerReviewFlags/registerScanFlags。
|
|
3250
4612
|
*/
|
|
3251
|
-
function tuningParams(messages, unit) {
|
|
4613
|
+
function tuningParams(messages, unit, maxToolsTemplate) {
|
|
3252
4614
|
const of = (template) => ({
|
|
3253
4615
|
type: "integer",
|
|
3254
4616
|
description: format(template, { unit })
|
|
@@ -3256,7 +4618,7 @@ function tuningParams(messages, unit) {
|
|
|
3256
4618
|
return {
|
|
3257
4619
|
concurrency: of(messages.tuningConcurrency),
|
|
3258
4620
|
timeoutMinutes: of(messages.tuningTimeout),
|
|
3259
|
-
maxTools: of(
|
|
4621
|
+
maxTools: of(maxToolsTemplate),
|
|
3260
4622
|
maxTokens: of(messages.tuningMaxTokens),
|
|
3261
4623
|
maxTokensBudget: {
|
|
3262
4624
|
type: "integer",
|
|
@@ -3269,10 +4631,11 @@ function tuningParams(messages, unit) {
|
|
|
3269
4631
|
* 外部仍以 `Config` 名导入(export as),公开 API 不变。 */
|
|
3270
4632
|
const configSchema = Schema.object({
|
|
3271
4633
|
effort: Schema.union([
|
|
4634
|
+
"auto",
|
|
3272
4635
|
"low",
|
|
3273
4636
|
"medium",
|
|
3274
4637
|
"high"
|
|
3275
|
-
]).default("
|
|
4638
|
+
]).default("auto").volatile(),
|
|
3276
4639
|
language: Schema.union(["中文", "English"]).default("中文").volatile(),
|
|
3277
4640
|
autoVerify: Schema.boolean().default(true).volatile(),
|
|
3278
4641
|
maxComments: Schema.natural().default(MAX_COMMENTS_DEFAULT).volatile(),
|
|
@@ -3308,7 +4671,7 @@ function pluginPaths(config, messages) {
|
|
|
3308
4671
|
/**
|
|
3309
4672
|
* ocr 子进程的 HOME 重定向值(工具执行侧专用,**不抛错**):设置项命中
|
|
3310
4673
|
* `<X>/.opencodereview/config.json` 布局 → X;未设置/坏设置 → undefined。工具不
|
|
3311
|
-
* 走 pluginPaths
|
|
4674
|
+
* 走 pluginPaths(那会在坏设置下把七个工具一并拖死),坏设置的错误由端点侧的
|
|
3312
4675
|
* resolveOcrConfigPath 报给卡片——「工具与设置照常可用」的口径不变。
|
|
3313
4676
|
*/
|
|
3314
4677
|
function ocrHome(config) {
|
|
@@ -3321,7 +4684,7 @@ function ocrHome(config) {
|
|
|
3321
4684
|
* 为什么必须在这一层而不是启动时:config.json 里的脚本路径是**写那一刻**的插件安装
|
|
3322
4685
|
* 目录,而插件会被重装、换 profile(desktop / browser / 任意自定义名)、换发布形态。
|
|
3323
4686
|
* 目录一变那条命令就悬空,ocr 侧报的是「resolve LLM endpoint 失败」,与真因毫无
|
|
3324
|
-
*
|
|
4687
|
+
* 关系——七个工具一次性全挂,而配置文件里没有任何线索指向「本包被挪了地方」。
|
|
3325
4688
|
* 当前路径一律取自 PLUGIN_DIR(import.meta.dirname),与 profile 命名
|
|
3326
4689
|
* 无关,故 desktop / browser / 自定义名走的是同一条路径。
|
|
3327
4690
|
*
|
|
@@ -3360,7 +4723,7 @@ function probeFailureReason(messages, exitCode, timedOut) {
|
|
|
3360
4723
|
* **全局安装**(`scripts/update.js` 见到更新版本就 spawnSync `npm i -g <pkg>@<latest>`,
|
|
3361
4724
|
* 默认 18 分钟冷却一次)。状态读侧不该有写侧副作用,所以这条不受 `ocrNoAutoUpdate`
|
|
3362
4725
|
* 那个部署开关影响,恒定带上。
|
|
3363
|
-
* - HOME
|
|
4726
|
+
* - HOME 与七个工具走同一个重定向口。launcher 那句「自动更新没成功,请手工执行」的
|
|
3364
4727
|
* 提示写在 `$HOME/.opencodereview/` 下,不带重定向就会去读另一个世界的状态,
|
|
3365
4728
|
* 卡片报的与评审实际吃到的就不是同一份。
|
|
3366
4729
|
* @param host 宿主服务面
|
|
@@ -3450,7 +4813,7 @@ function cliStatusPayload(status, config, messages, measured) {
|
|
|
3450
4813
|
* @returns 同一份摘要,或 hint 尾部多了一句的副本。
|
|
3451
4814
|
*/
|
|
3452
4815
|
function withCliVersionNote(summary, measured, messages) {
|
|
3453
|
-
if (measured === null || measured === "1.13.
|
|
4816
|
+
if (measured === null || measured === "1.13.3") return summary;
|
|
3454
4817
|
const note = format(messages.versionMismatch, {
|
|
3455
4818
|
actual: measured,
|
|
3456
4819
|
expected: EXPECTED_OCR_CLI_VERSION
|
|
@@ -3461,7 +4824,7 @@ function withCliVersionNote(summary, measured, messages) {
|
|
|
3461
4824
|
};
|
|
3462
4825
|
}
|
|
3463
4826
|
/** 运行 ocr llm test 校验当前配置(select 后自动验证 / 手动测试共用)。
|
|
3464
|
-
*
|
|
4827
|
+
* 命令词与七个工具同一解析源(buildLlmTestCommand 走 ocrCommand():卡片验的那一份与评审
|
|
3465
4828
|
* 吃的那一份必须同一个二进制),HOME 随 ocrConfigPath 重定向。墙钟与输出上限都是 entry
|
|
3466
4829
|
* config 字段(非 volatile,改值随重启生效)。 */
|
|
3467
4830
|
async function runLlmTest(host, config) {
|
|
@@ -3475,8 +4838,8 @@ async function runLlmTest(host, config) {
|
|
|
3475
4838
|
output
|
|
3476
4839
|
};
|
|
3477
4840
|
}
|
|
3478
|
-
/** effort 入参闸:undefined/null →
|
|
3479
|
-
*
|
|
4841
|
+
/** effort 入参闸:undefined/null → 设置卡默认(auto 表示跟随 OCR 配置);字符串 →
|
|
4842
|
+
* 白名单钳制(未知值回落 auto,与 --format 同一纪律);其余类型抛错——静默回落会改变审查深度。 */
|
|
3480
4843
|
function effortOf(value, config, messages) {
|
|
3481
4844
|
if (value === void 0 || value === null) return readEffort(config);
|
|
3482
4845
|
if (typeof value !== "string") throw new TypeError(format(messages.mustBeString, {
|
|
@@ -3485,13 +4848,33 @@ function effortOf(value, config, messages) {
|
|
|
3485
4848
|
}));
|
|
3486
4849
|
return clampEffort(value, "medium");
|
|
3487
4850
|
}
|
|
3488
|
-
/**
|
|
3489
|
-
*
|
|
4851
|
+
/**
|
|
4852
|
+
* wait 入参闸:undefined → **false(后台缺省)**;布尔透传;其余类型抛错——字符串
|
|
4853
|
+
* "false" 若被 `!== false` 当真值处理会静默从后台变前台,吞掉用户的等待语义。
|
|
4854
|
+
*
|
|
4855
|
+
* 为什么缺省改成后台:一次 review 是分钟级长任务(真跑过 8 分钟以上),前台缺省会让模型
|
|
4856
|
+
* 在一次工具调用里干等,既占住轮次又容易撞上宿主超时;后台立刻返回作业 id,模型用
|
|
4857
|
+
* `ocr_session` 轮询,等待因此发生在**模型自己的节奏里**而不是一次阻塞调用里。
|
|
4858
|
+
* 想要旧行为(一次拿到结果)显式传 `wait: true` 即可。
|
|
4859
|
+
*/
|
|
3490
4860
|
function waitOf(value, messages) {
|
|
3491
|
-
if (value === void 0 || value === null) return
|
|
4861
|
+
if (value === void 0 || value === null) return false;
|
|
3492
4862
|
if (typeof value !== "boolean") throw new TypeError(format(messages.waitMustBeBoolean, { received: describeValue(value) }));
|
|
3493
4863
|
return value;
|
|
3494
4864
|
}
|
|
4865
|
+
/** session action 的只读归一:错类型交给 builder 的消息表闸。 */
|
|
4866
|
+
function actionOf(value) {
|
|
4867
|
+
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
4868
|
+
}
|
|
4869
|
+
/** preview 入参闸:缺省 false;非布尔抛错。 */
|
|
4870
|
+
function previewOf(value, messages) {
|
|
4871
|
+
if (value === void 0 || value === null) return false;
|
|
4872
|
+
if (typeof value !== "boolean") throw new TypeError(format(messages.boolMustBeBoolean, {
|
|
4873
|
+
name: "preview",
|
|
4874
|
+
received: describeValue(value)
|
|
4875
|
+
}));
|
|
4876
|
+
return value;
|
|
4877
|
+
}
|
|
3495
4878
|
/** 服务界面齐备才算可用(get() 可能返回半装配对象,也可能返回 undefined)。 */
|
|
3496
4879
|
function usableService(value, methods) {
|
|
3497
4880
|
return value !== void 0 && hasMethods(value, methods) ? value : void 0;
|
|
@@ -3711,13 +5094,93 @@ async function testReply(deps) {
|
|
|
3711
5094
|
};
|
|
3712
5095
|
}
|
|
3713
5096
|
}
|
|
5097
|
+
async function mcpReply(deps) {
|
|
5098
|
+
const msgs = deps.messages();
|
|
5099
|
+
try {
|
|
5100
|
+
const result = await readMcpServers(pluginPaths(deps.config, msgs).ocrConfigJson, msgs);
|
|
5101
|
+
return result.ok ? {
|
|
5102
|
+
ok: true,
|
|
5103
|
+
json: result.json
|
|
5104
|
+
} : {
|
|
5105
|
+
ok: false,
|
|
5106
|
+
error: result.error,
|
|
5107
|
+
status: 400
|
|
5108
|
+
};
|
|
5109
|
+
} catch (error) {
|
|
5110
|
+
return {
|
|
5111
|
+
ok: false,
|
|
5112
|
+
error: errorText(error),
|
|
5113
|
+
status: 500
|
|
5114
|
+
};
|
|
5115
|
+
}
|
|
5116
|
+
}
|
|
5117
|
+
async function saveMcpReply(json, deps) {
|
|
5118
|
+
const msgs = deps.messages();
|
|
5119
|
+
if (typeof json !== "string") return {
|
|
5120
|
+
ok: false,
|
|
5121
|
+
error: format(msgs.mustBeString, {
|
|
5122
|
+
name: "json",
|
|
5123
|
+
received: describeValue(json)
|
|
5124
|
+
}),
|
|
5125
|
+
status: 400
|
|
5126
|
+
};
|
|
5127
|
+
const result = await saveMcpServers(pluginPaths(deps.config, msgs).ocrConfigJson, json, msgs);
|
|
5128
|
+
return result.ok ? {
|
|
5129
|
+
ok: true,
|
|
5130
|
+
count: result.count
|
|
5131
|
+
} : {
|
|
5132
|
+
ok: false,
|
|
5133
|
+
error: result.error,
|
|
5134
|
+
status: 400
|
|
5135
|
+
};
|
|
5136
|
+
}
|
|
5137
|
+
async function authReply(deps) {
|
|
5138
|
+
try {
|
|
5139
|
+
const { exitCode, output } = await runCollect(deps.host, withHomeEnv(buildAuthStatusCommand(), ocrHome(deps.config)), process.env["HOME"] ?? "/", {
|
|
5140
|
+
timeoutMs: deps.config.llmTestTimeoutMs,
|
|
5141
|
+
stdoutMaxBytes: deps.config.stdoutMaxBytes,
|
|
5142
|
+
env: ocrEnv(deps.config)
|
|
5143
|
+
});
|
|
5144
|
+
return {
|
|
5145
|
+
ok: exitCode === 0,
|
|
5146
|
+
output,
|
|
5147
|
+
loginCommand: CHATGPT_LOGIN_COMMAND
|
|
5148
|
+
};
|
|
5149
|
+
} catch (error) {
|
|
5150
|
+
return {
|
|
5151
|
+
ok: false,
|
|
5152
|
+
error: errorText(error),
|
|
5153
|
+
status: 400
|
|
5154
|
+
};
|
|
5155
|
+
}
|
|
5156
|
+
}
|
|
5157
|
+
async function chatGptReply(model, deps) {
|
|
5158
|
+
const msgs = deps.messages();
|
|
5159
|
+
if (typeof model !== "string") return {
|
|
5160
|
+
ok: false,
|
|
5161
|
+
error: format(msgs.mustBeString, {
|
|
5162
|
+
name: "model",
|
|
5163
|
+
received: describeValue(model)
|
|
5164
|
+
}),
|
|
5165
|
+
status: 400
|
|
5166
|
+
};
|
|
5167
|
+
const result = await applyChatGptModel(pluginPaths(deps.config, msgs).ocrConfigJson, model, msgs);
|
|
5168
|
+
return result.ok ? {
|
|
5169
|
+
ok: true,
|
|
5170
|
+
model: model.trim()
|
|
5171
|
+
} : {
|
|
5172
|
+
ok: false,
|
|
5173
|
+
error: result.error,
|
|
5174
|
+
status: 400
|
|
5175
|
+
};
|
|
5176
|
+
}
|
|
3714
5177
|
/**
|
|
3715
|
-
*
|
|
5178
|
+
* 设置卡的九条读/写方法(providers / select / migrate / test / probe / mcp / saveMcp / auth / chatGpt)。
|
|
3716
5179
|
*
|
|
3717
5180
|
* ⚠ 服务在**构造时**注册(cordis `Service` 的契约:`ctx.reflect.provide`,随所属 fiber
|
|
3718
5181
|
* 卸载自动撤),所以调用方拿实例没有任何用处——装入口 `installOcrRemote` 说明了这一点。
|
|
3719
|
-
*
|
|
3720
|
-
* 无条件上线:宿主没有 web 层(TUI
|
|
5182
|
+
* 九个方法都不依赖可选宿主服务(gateway 与取用口都由 apply 交进来),故它跟着 apply
|
|
5183
|
+
* 无条件上线:宿主没有 web 层(TUI)时这九条照样在。
|
|
3721
5184
|
*/
|
|
3722
5185
|
var OcrReviewRemoteService = class extends TypertRemoteService {
|
|
3723
5186
|
deps;
|
|
@@ -3751,6 +5214,22 @@ var OcrReviewRemoteService = class extends TypertRemoteService {
|
|
|
3751
5214
|
versionSink: this.deps.extras.versionSink
|
|
3752
5215
|
});
|
|
3753
5216
|
}
|
|
5217
|
+
/** 读当前 `mcp_servers`(只回这一支,不带其他配置/keys)。 */
|
|
5218
|
+
async mcp() {
|
|
5219
|
+
return mcpReply(this.deps);
|
|
5220
|
+
}
|
|
5221
|
+
/** 校验并写入 `mcp_servers`;其他配置原样保留。 */
|
|
5222
|
+
async saveMcp(request) {
|
|
5223
|
+
return saveMcpReply(isRecord(request) ? request.json : void 0, this.deps);
|
|
5224
|
+
}
|
|
5225
|
+
/** 只读查询 ChatGPT 登录态;登录命令原样带回给卡片显示。 */
|
|
5226
|
+
async auth() {
|
|
5227
|
+
return authReply(this.deps);
|
|
5228
|
+
}
|
|
5229
|
+
/** 切到 ChatGPT provider 并写模型;key 由 OCR 的 auth 凭据库管,不写明文。 */
|
|
5230
|
+
async chatGpt(request) {
|
|
5231
|
+
return chatGptReply(isRecord(request) ? request.model : void 0, this.deps);
|
|
5232
|
+
}
|
|
3754
5233
|
};
|
|
3755
5234
|
/**
|
|
3756
5235
|
* 标准装饰器的**程序化调用**:`@Remote` 的全部作用就是往类原型写一枚描述符,而标准装饰器
|
|
@@ -3775,6 +5254,10 @@ markRemote(OcrReviewRemoteService.prototype, "select");
|
|
|
3775
5254
|
markRemote(OcrReviewRemoteService.prototype, "migrate");
|
|
3776
5255
|
markRemote(OcrReviewRemoteService.prototype, "test");
|
|
3777
5256
|
markRemote(OcrReviewRemoteService.prototype, "probe");
|
|
5257
|
+
markRemote(OcrReviewRemoteService.prototype, "mcp");
|
|
5258
|
+
markRemote(OcrReviewRemoteService.prototype, "saveMcp");
|
|
5259
|
+
markRemote(OcrReviewRemoteService.prototype, "auth");
|
|
5260
|
+
markRemote(OcrReviewRemoteService.prototype, "chatGpt");
|
|
3778
5261
|
/**
|
|
3779
5262
|
* 把读面装到这个上下文上(构造即注册,见类文档;host 侧唯一该调的口)。
|
|
3780
5263
|
*
|
|
@@ -3812,6 +5295,70 @@ const productionProbe = () => resolveOcrAvailability();
|
|
|
3812
5295
|
function assertOcrPresent(status, messages, install) {
|
|
3813
5296
|
if (status.source === "none") throw new Error(format(messages.ocrBinaryMissing, { install }));
|
|
3814
5297
|
}
|
|
5298
|
+
/** review 的预览支路:零 LLM、前台、临时 --output;抽出来只为把注册函数压回行数上限。 */
|
|
5299
|
+
async function runReviewPreview(run) {
|
|
5300
|
+
const { host, config, args, repo, signal, messages } = run;
|
|
5301
|
+
if (args["wait"] !== void 0 && args["wait"] !== null && !waitOf(args["wait"], messages)) throw new Error(messages.previewNeedsForeground);
|
|
5302
|
+
const tmp = mkdtempSync(path.join(tmpdir(), "ocr-review-preview-"));
|
|
5303
|
+
const outPath = path.join(tmp, "preview.json");
|
|
5304
|
+
try {
|
|
5305
|
+
const { command, workdir } = buildReviewCommand({
|
|
5306
|
+
...args,
|
|
5307
|
+
repo,
|
|
5308
|
+
preview: true
|
|
5309
|
+
}, outPath, messages);
|
|
5310
|
+
await runForeground(host, withHomeEnv(command, ocrHome(config)), workdir, tuningOf(config), signal);
|
|
5311
|
+
const text = readOutFile(outPath, messages);
|
|
5312
|
+
const parsed = parsePreviewOutput(text);
|
|
5313
|
+
if (!parsed.ok) return format(messages.parseFailedWithReason, {
|
|
5314
|
+
tool: "ocr review --preview",
|
|
5315
|
+
message: messages.previewParseFailed,
|
|
5316
|
+
limit: String(RAW_ECHO_LIMIT),
|
|
5317
|
+
raw: echoRaw(text, RAW_ECHO_LIMIT)
|
|
5318
|
+
});
|
|
5319
|
+
return {
|
|
5320
|
+
...parsed.view,
|
|
5321
|
+
hint: messages.filePreviewHint
|
|
5322
|
+
};
|
|
5323
|
+
} finally {
|
|
5324
|
+
rmSync(tmp, {
|
|
5325
|
+
recursive: true,
|
|
5326
|
+
force: true
|
|
5327
|
+
});
|
|
5328
|
+
}
|
|
5329
|
+
}
|
|
5330
|
+
/** scan 的预览支路(与 review 同形,只是不归一化 git 顶层)。 */
|
|
5331
|
+
async function runScanPreview(run) {
|
|
5332
|
+
const { host, config, args, repo, signal, messages } = run;
|
|
5333
|
+
if (args["wait"] !== void 0 && args["wait"] !== null && !waitOf(args["wait"], messages)) throw new Error(messages.previewNeedsForeground);
|
|
5334
|
+
const tmp = mkdtempSync(path.join(tmpdir(), "ocr-scan-preview-"));
|
|
5335
|
+
const outPath = path.join(tmp, "preview.json");
|
|
5336
|
+
try {
|
|
5337
|
+
const { command, workdir } = buildScanCommand({
|
|
5338
|
+
...args,
|
|
5339
|
+
repo,
|
|
5340
|
+
preview: true
|
|
5341
|
+
}, outPath, messages);
|
|
5342
|
+
await runForeground(host, withHomeEnv(command, ocrHome(config)), workdir, tuningOf(config), signal);
|
|
5343
|
+
const text = readOutFile(outPath, messages);
|
|
5344
|
+
const parsed = parsePreviewOutput(text);
|
|
5345
|
+
if (!parsed.ok) return format(messages.parseFailedWithReason, {
|
|
5346
|
+
tool: "ocr scan --preview",
|
|
5347
|
+
message: messages.previewParseFailed,
|
|
5348
|
+
limit: String(RAW_ECHO_LIMIT),
|
|
5349
|
+
raw: echoRaw(text, RAW_ECHO_LIMIT)
|
|
5350
|
+
});
|
|
5351
|
+
return {
|
|
5352
|
+
...parsed.view,
|
|
5353
|
+
hint: messages.filePreviewHint
|
|
5354
|
+
};
|
|
5355
|
+
} finally {
|
|
5356
|
+
rmSync(tmp, {
|
|
5357
|
+
recursive: true,
|
|
5358
|
+
force: true
|
|
5359
|
+
});
|
|
5360
|
+
}
|
|
5361
|
+
}
|
|
3815
5362
|
function registerReviewTool(host, config, probe, versionSink) {
|
|
3816
5363
|
const msgs = localeMessages(host);
|
|
3817
5364
|
define(host, {
|
|
@@ -3864,7 +5411,7 @@ function registerReviewTool(host, config, probe, versionSink) {
|
|
|
3864
5411
|
items: { type: "string" },
|
|
3865
5412
|
description: msgs.paramExclude
|
|
3866
5413
|
},
|
|
3867
|
-
...tuningParams(msgs, msgs.unitGroup),
|
|
5414
|
+
...tuningParams(msgs, msgs.unitGroup, msgs.tuningMaxTools),
|
|
3868
5415
|
wait: {
|
|
3869
5416
|
type: "boolean",
|
|
3870
5417
|
description: msgs.paramWait
|
|
@@ -3880,6 +5427,26 @@ function registerReviewTool(host, config, probe, versionSink) {
|
|
|
3880
5427
|
resume: {
|
|
3881
5428
|
type: "string",
|
|
3882
5429
|
description: msgs.paramResume
|
|
5430
|
+
},
|
|
5431
|
+
preview: {
|
|
5432
|
+
type: "boolean",
|
|
5433
|
+
description: msgs.paramPreview
|
|
5434
|
+
},
|
|
5435
|
+
noFilter: {
|
|
5436
|
+
type: "boolean",
|
|
5437
|
+
description: msgs.paramNoFilter
|
|
5438
|
+
},
|
|
5439
|
+
maxGitProcs: {
|
|
5440
|
+
type: "integer",
|
|
5441
|
+
description: msgs.paramMaxGitProcs
|
|
5442
|
+
},
|
|
5443
|
+
rule: {
|
|
5444
|
+
type: "string",
|
|
5445
|
+
description: msgs.paramRule
|
|
5446
|
+
},
|
|
5447
|
+
backgroundFile: {
|
|
5448
|
+
type: "string",
|
|
5449
|
+
description: msgs.paramBackgroundFile
|
|
3883
5450
|
}
|
|
3884
5451
|
}
|
|
3885
5452
|
},
|
|
@@ -3889,6 +5456,14 @@ function registerReviewTool(host, config, probe, versionSink) {
|
|
|
3889
5456
|
const cliStatus = probe();
|
|
3890
5457
|
assertOcrPresent(cliStatus, runMsgs, ocrInstallText(cliStatus, config, runMsgs));
|
|
3891
5458
|
const repo = await canonicalReviewRoot(host, ocrRepo(args, exec, runMsgs));
|
|
5459
|
+
if (previewOf(args["preview"], runMsgs)) return runReviewPreview({
|
|
5460
|
+
host,
|
|
5461
|
+
config,
|
|
5462
|
+
args,
|
|
5463
|
+
repo,
|
|
5464
|
+
signal: exec.signal,
|
|
5465
|
+
messages: runMsgs
|
|
5466
|
+
});
|
|
3892
5467
|
const eff = effortOf(args["effort"], config, runMsgs);
|
|
3893
5468
|
const tmp = waitOf(args["wait"], runMsgs) ? mkdtempSync(path.join(tmpdir(), "ocr-review-")) : void 0;
|
|
3894
5469
|
const outPath = tmp === void 0 ? void 0 : path.join(tmp, "out.json");
|
|
@@ -3910,7 +5485,11 @@ function registerReviewTool(host, config, probe, versionSink) {
|
|
|
3910
5485
|
backgroundMaxMs: config.ocrBackgroundMaxMs,
|
|
3911
5486
|
env: ocrEnv(config)
|
|
3912
5487
|
});
|
|
3913
|
-
|
|
5488
|
+
try {
|
|
5489
|
+
await runForeground(host, guarded, workdir, tuningOf(config), exec.signal);
|
|
5490
|
+
} catch (error) {
|
|
5491
|
+
throw withCoverageDetail(error, outPath, runMsgs);
|
|
5492
|
+
}
|
|
3914
5493
|
const text = readOutFile(outPath, runMsgs);
|
|
3915
5494
|
const parsed = parseReviewOutput(text, runMsgs);
|
|
3916
5495
|
if (!parsed.ok) return format(runMsgs.parseFailedWithReason, {
|
|
@@ -3973,10 +5552,38 @@ function registerScanTool(host, config, probe, versionSink) {
|
|
|
3973
5552
|
type: "string",
|
|
3974
5553
|
description: msgs.paramModel
|
|
3975
5554
|
},
|
|
3976
|
-
...tuningParams(msgs, msgs.unitFile),
|
|
5555
|
+
...tuningParams(msgs, msgs.unitFile, msgs.tuningMaxToolsScan),
|
|
3977
5556
|
wait: {
|
|
3978
5557
|
type: "boolean",
|
|
3979
5558
|
description: msgs.paramWait
|
|
5559
|
+
},
|
|
5560
|
+
preview: {
|
|
5561
|
+
type: "boolean",
|
|
5562
|
+
description: msgs.paramPreview
|
|
5563
|
+
},
|
|
5564
|
+
noPlan: {
|
|
5565
|
+
type: "boolean",
|
|
5566
|
+
description: msgs.paramNoPlan
|
|
5567
|
+
},
|
|
5568
|
+
noDedup: {
|
|
5569
|
+
type: "boolean",
|
|
5570
|
+
description: msgs.paramNoDedup
|
|
5571
|
+
},
|
|
5572
|
+
noSummary: {
|
|
5573
|
+
type: "boolean",
|
|
5574
|
+
description: msgs.paramNoSummary
|
|
5575
|
+
},
|
|
5576
|
+
resume: {
|
|
5577
|
+
type: "string",
|
|
5578
|
+
description: msgs.paramScanResume
|
|
5579
|
+
},
|
|
5580
|
+
maxGitProcs: {
|
|
5581
|
+
type: "integer",
|
|
5582
|
+
description: msgs.paramMaxGitProcs
|
|
5583
|
+
},
|
|
5584
|
+
rule: {
|
|
5585
|
+
type: "string",
|
|
5586
|
+
description: msgs.paramRule
|
|
3980
5587
|
}
|
|
3981
5588
|
}
|
|
3982
5589
|
},
|
|
@@ -3986,6 +5593,14 @@ function registerScanTool(host, config, probe, versionSink) {
|
|
|
3986
5593
|
const cliStatus = probe();
|
|
3987
5594
|
assertOcrPresent(cliStatus, runMsgs, ocrInstallText(cliStatus, config, runMsgs));
|
|
3988
5595
|
const repo = ocrRepo(args, exec, runMsgs);
|
|
5596
|
+
if (previewOf(args["preview"], runMsgs)) return runScanPreview({
|
|
5597
|
+
host,
|
|
5598
|
+
config,
|
|
5599
|
+
args,
|
|
5600
|
+
repo,
|
|
5601
|
+
signal: exec.signal,
|
|
5602
|
+
messages: runMsgs
|
|
5603
|
+
});
|
|
3989
5604
|
const tmp = waitOf(args["wait"], runMsgs) ? mkdtempSync(path.join(tmpdir(), "ocr-scan-")) : void 0;
|
|
3990
5605
|
const outPath = tmp === void 0 ? void 0 : path.join(tmp, "out.json");
|
|
3991
5606
|
await healKeyScriptPointer(host, config);
|
|
@@ -4005,7 +5620,11 @@ function registerScanTool(host, config, probe, versionSink) {
|
|
|
4005
5620
|
backgroundMaxMs: config.ocrBackgroundMaxMs,
|
|
4006
5621
|
env: ocrEnv(config)
|
|
4007
5622
|
});
|
|
4008
|
-
|
|
5623
|
+
try {
|
|
5624
|
+
await runForeground(host, guarded, workdir, tuningOf(config), exec.signal);
|
|
5625
|
+
} catch (error) {
|
|
5626
|
+
throw withCoverageDetail(error, outPath, runMsgs);
|
|
5627
|
+
}
|
|
4009
5628
|
const text = readOutFile(outPath, runMsgs);
|
|
4010
5629
|
const parsed = parseReviewOutput(text, runMsgs);
|
|
4011
5630
|
if (!parsed.ok) return format(runMsgs.parseFailedWithReason, {
|
|
@@ -4170,7 +5789,9 @@ function registerSessionTool(host, config, probe) {
|
|
|
4170
5789
|
enum: [
|
|
4171
5790
|
"list",
|
|
4172
5791
|
"show",
|
|
4173
|
-
"comments"
|
|
5792
|
+
"comments",
|
|
5793
|
+
"compare",
|
|
5794
|
+
"export"
|
|
4174
5795
|
],
|
|
4175
5796
|
description: msgs.paramSessionAction
|
|
4176
5797
|
},
|
|
@@ -4178,6 +5799,24 @@ function registerSessionTool(host, config, probe) {
|
|
|
4178
5799
|
type: "string",
|
|
4179
5800
|
description: msgs.paramSessionId
|
|
4180
5801
|
},
|
|
5802
|
+
before: {
|
|
5803
|
+
type: "string",
|
|
5804
|
+
description: msgs.paramSessionBefore
|
|
5805
|
+
},
|
|
5806
|
+
after: {
|
|
5807
|
+
type: "string",
|
|
5808
|
+
description: msgs.paramSessionAfter
|
|
5809
|
+
},
|
|
5810
|
+
severity: {
|
|
5811
|
+
type: "array",
|
|
5812
|
+
items: { type: "string" },
|
|
5813
|
+
description: msgs.paramSessionSeverity
|
|
5814
|
+
},
|
|
5815
|
+
category: {
|
|
5816
|
+
type: "array",
|
|
5817
|
+
items: { type: "string" },
|
|
5818
|
+
description: msgs.paramSessionCategory
|
|
5819
|
+
},
|
|
4181
5820
|
limit: {
|
|
4182
5821
|
type: "integer",
|
|
4183
5822
|
description: msgs.paramSessionLimit
|
|
@@ -4190,6 +5829,47 @@ function registerSessionTool(host, config, probe) {
|
|
|
4190
5829
|
const cliStatus = probe();
|
|
4191
5830
|
assertOcrPresent(cliStatus, runMsgs, ocrInstallText(cliStatus, config, runMsgs));
|
|
4192
5831
|
const repo = await canonicalReviewRoot(host, ocrRepo(args, exec, runMsgs));
|
|
5832
|
+
const action = actionOf(args["action"]);
|
|
5833
|
+
const before = typeof args["before"] === "string" ? args["before"].trim() : "";
|
|
5834
|
+
const after = typeof args["after"] === "string" ? args["after"].trim() : "";
|
|
5835
|
+
if (action === "compare" || action === "" && (before !== "" || after !== "")) {
|
|
5836
|
+
const { command, workdir } = buildSessionCommand({
|
|
5837
|
+
...args,
|
|
5838
|
+
repo
|
|
5839
|
+
}, runMsgs);
|
|
5840
|
+
const stdout = await runForeground(host, withHomeEnv(command, ocrHome(config)), workdir, tuningOf(config), exec.signal);
|
|
5841
|
+
const parsed = parseSessionCompare(stdout.text);
|
|
5842
|
+
if (parsed.ok) return {
|
|
5843
|
+
...parsed.view,
|
|
5844
|
+
hint: runMsgs.sessionCompareHint
|
|
5845
|
+
};
|
|
5846
|
+
return {
|
|
5847
|
+
sessionOutput: echoRaw(stdout.text, RAW_ECHO_LIMIT) + stdoutTruncationNote(config.stdoutMaxBytes, stdout, runMsgs),
|
|
5848
|
+
hint: runMsgs.sessionCompareHint
|
|
5849
|
+
};
|
|
5850
|
+
}
|
|
5851
|
+
if (action === "export") {
|
|
5852
|
+
const exportDir = mkdtempSync(path.join(tmpdir(), "ocr-session-export-"));
|
|
5853
|
+
const output = path.join(exportDir, "review.html");
|
|
5854
|
+
try {
|
|
5855
|
+
const { command, workdir } = buildSessionCommand({
|
|
5856
|
+
...args,
|
|
5857
|
+
repo,
|
|
5858
|
+
output
|
|
5859
|
+
}, runMsgs);
|
|
5860
|
+
await runForeground(host, withHomeEnv(command, ocrHome(config)), workdir, tuningOf(config), exec.signal);
|
|
5861
|
+
return {
|
|
5862
|
+
sessionOutput: output,
|
|
5863
|
+
hint: runMsgs.sessionExportHint
|
|
5864
|
+
};
|
|
5865
|
+
} catch (error) {
|
|
5866
|
+
rmSync(exportDir, {
|
|
5867
|
+
recursive: true,
|
|
5868
|
+
force: true
|
|
5869
|
+
});
|
|
5870
|
+
throw error;
|
|
5871
|
+
}
|
|
5872
|
+
}
|
|
4193
5873
|
const { command, workdir } = buildSessionCommand({
|
|
4194
5874
|
...args,
|
|
4195
5875
|
repo
|
|
@@ -4206,6 +5886,83 @@ function registerSessionTool(host, config, probe) {
|
|
|
4206
5886
|
}
|
|
4207
5887
|
});
|
|
4208
5888
|
}
|
|
5889
|
+
function registerRulesCheckTool(host, config, probe) {
|
|
5890
|
+
const msgs = localeMessages(host);
|
|
5891
|
+
define(host, {
|
|
5892
|
+
name: "ocr_rules_check",
|
|
5893
|
+
description: msgs.rulesCheckToolDescription,
|
|
5894
|
+
parameters: {
|
|
5895
|
+
type: "object",
|
|
5896
|
+
additionalProperties: false,
|
|
5897
|
+
properties: {
|
|
5898
|
+
repo: {
|
|
5899
|
+
type: "string",
|
|
5900
|
+
description: msgs.paramRepo
|
|
5901
|
+
},
|
|
5902
|
+
path: {
|
|
5903
|
+
type: "string",
|
|
5904
|
+
description: msgs.paramRulesPath
|
|
5905
|
+
},
|
|
5906
|
+
rule: {
|
|
5907
|
+
type: "string",
|
|
5908
|
+
description: msgs.paramRule
|
|
5909
|
+
}
|
|
5910
|
+
},
|
|
5911
|
+
required: ["path"]
|
|
5912
|
+
},
|
|
5913
|
+
schema: TEXT_OUTPUT_SCHEMA,
|
|
5914
|
+
run: async (args, exec) => {
|
|
5915
|
+
const runMsgs = localeMessages(host);
|
|
5916
|
+
const cliStatus = probe();
|
|
5917
|
+
assertOcrPresent(cliStatus, runMsgs, ocrInstallText(cliStatus, config, runMsgs));
|
|
5918
|
+
const repo = ocrRepo(args, exec, runMsgs);
|
|
5919
|
+
const { command, workdir } = buildRulesCheckCommand({
|
|
5920
|
+
...args,
|
|
5921
|
+
repo
|
|
5922
|
+
}, runMsgs);
|
|
5923
|
+
const stdout = await runForeground(host, withHomeEnv(command, ocrHome(config)), workdir, tuningOf(config), exec.signal);
|
|
5924
|
+
const { text } = stdout;
|
|
5925
|
+
return {
|
|
5926
|
+
output: (text.length > 12e3 ? `${echoRaw(text, 11900)}\n${format(runMsgs.textTruncated, { limit: "12000" })}` : redactKeyMaterial(text)) + stdoutTruncationNote(config.stdoutMaxBytes, stdout, runMsgs),
|
|
5927
|
+
hint: runMsgs.rulesCheckHint
|
|
5928
|
+
};
|
|
5929
|
+
}
|
|
5930
|
+
});
|
|
5931
|
+
}
|
|
5932
|
+
function registerLlmTool(host, config, probe) {
|
|
5933
|
+
const msgs = localeMessages(host);
|
|
5934
|
+
define(host, {
|
|
5935
|
+
name: "ocr_llm",
|
|
5936
|
+
description: msgs.llmToolDescription,
|
|
5937
|
+
parameters: {
|
|
5938
|
+
type: "object",
|
|
5939
|
+
additionalProperties: false,
|
|
5940
|
+
properties: { action: {
|
|
5941
|
+
type: "string",
|
|
5942
|
+
enum: ["providers", "models"],
|
|
5943
|
+
description: msgs.paramLlmAction
|
|
5944
|
+
} },
|
|
5945
|
+
required: ["action"]
|
|
5946
|
+
},
|
|
5947
|
+
schema: TEXT_OUTPUT_SCHEMA,
|
|
5948
|
+
run: async (args, exec) => {
|
|
5949
|
+
const runMsgs = localeMessages(host);
|
|
5950
|
+
const cliStatus = probe();
|
|
5951
|
+
assertOcrPresent(cliStatus, runMsgs, ocrInstallText(cliStatus, config, runMsgs));
|
|
5952
|
+
const rawAction = args["action"];
|
|
5953
|
+
if (rawAction !== "providers" && rawAction !== "models") throw new Error(format(runMsgs.invalidAction, {
|
|
5954
|
+
action: typeof rawAction === "string" ? rawAction : describeValue(rawAction),
|
|
5955
|
+
expected: "providers/models"
|
|
5956
|
+
}));
|
|
5957
|
+
const stdout = await runForeground(host, withHomeEnv(buildLlmCommand(rawAction), ocrHome(config)), process.cwd(), tuningOf(config), exec.signal);
|
|
5958
|
+
const { text } = stdout;
|
|
5959
|
+
return {
|
|
5960
|
+
output: (text.length > 12e3 ? `${echoRaw(text, 11900)}\n${format(runMsgs.textTruncated, { limit: "12000" })}` : redactKeyMaterial(text)) + stdoutTruncationNote(config.stdoutMaxBytes, stdout, runMsgs),
|
|
5961
|
+
hint: runMsgs.llmHint
|
|
5962
|
+
};
|
|
5963
|
+
}
|
|
5964
|
+
});
|
|
5965
|
+
}
|
|
4209
5966
|
/** 装载期自检结论里保留的输出上限(字符):卡片只用来提示,留全量没有意义。 */
|
|
4210
5967
|
const ENDPOINT_VERIFY_CHARS = 400;
|
|
4211
5968
|
/** 装载期端点自检(fire-and-forget):跑一次 `ocr llm test`,把结论写进本次 apply 的闭包,
|
|
@@ -4272,6 +6029,8 @@ function apply(ctx, config, probe) {
|
|
|
4272
6029
|
registerDelegatePreviewTool(host, config, probe);
|
|
4273
6030
|
registerDelegateRuleTool(host, config, probe);
|
|
4274
6031
|
registerSessionTool(host, config, probe);
|
|
6032
|
+
registerRulesCheckTool(host, config, probe);
|
|
6033
|
+
registerLlmTool(host, config, probe);
|
|
4275
6034
|
host.systemPrompt.section({
|
|
4276
6035
|
name: ROUTING_NAME,
|
|
4277
6036
|
order: ROUTING_ORDER,
|
|
@@ -4301,5 +6060,4 @@ var host_default = {
|
|
|
4301
6060
|
apply(ctx, config, productionProbe);
|
|
4302
6061
|
}
|
|
4303
6062
|
};
|
|
4304
|
-
//#endregion
|
|
4305
6063
|
export { configSchema as Config, OcrReviewRemoteService, apply, assertOcrPresent, createGateway, host_default as default, endpointVerifyOf, installOcrRemote, joinedOutput, ocrEnv, ocrRepo, productionProbe, readLanguage, requiredKeys };
|