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