@jayyuen66/dsh-ocr-review 0.1.0
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/LICENSE +21 -0
- package/README.md +233 -0
- package/client.js +1041 -0
- package/cordis.patch.yml +15 -0
- package/host.js +2936 -0
- package/icon.svg +1 -0
- package/package.json +112 -0
- package/scripts/get-cred.mjs +182 -0
package/host.js
ADDED
|
@@ -0,0 +1,2936 @@
|
|
|
1
|
+
// GENERATED by build-host.mjs — edit host.ts / lib/*.ts, then run `node build-host.mjs`.
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import Schema from "@deepseek-ai/schemastery";
|
|
4
|
+
import { chmodSync, copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
5
|
+
import { homedir, tmpdir } from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { credentialRef, isCredentialRefName } from "@deepseek-ai/dsh-credentials";
|
|
9
|
+
import { jobOutcomeOf } from "@jayyuen66/dsh-plugin-shared/lib/job-outcome";
|
|
10
|
+
import { guardBody, sendJson } from "@jayyuen66/dsh-plugin-shared/lib/http";
|
|
11
|
+
import { guardTrust } from "@jayyuen66/dsh-plugin-shared/lib/trust";
|
|
12
|
+
import { LOCALE_SETTINGS_NAMESPACE, messagesFor, resolveLocalePreference } from "@jayyuen66/dsh-plugin-shared/lib/locale";
|
|
13
|
+
import { truncateEnd, truncateStart } from "@jayyuen66/dsh-plugin-shared/lib/text";
|
|
14
|
+
import { fieldOf, isRecord } from "@jayyuen66/dsh-plugin-shared/lib/record";
|
|
15
|
+
import { errorText } from "@jayyuen66/dsh-plugin-shared/lib/errors";
|
|
16
|
+
import { mkdir } from "node:fs/promises";
|
|
17
|
+
import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
|
|
18
|
+
import { expandHomePath } from "@deepseek-ai/dsh-home-paths";
|
|
19
|
+
//#region lib/messages.ts
|
|
20
|
+
const MESSAGES = {
|
|
21
|
+
zh: {
|
|
22
|
+
middleEllipsis: " …[中间省略]… ",
|
|
23
|
+
execTimedOut: "ocr 执行超时(实际生效 {minutes} 分钟 = min(请求, shell.maxTimeoutMs)):请缩小审查范围(exclude/--commit/更小改动)、调低 OCR 每组 --timeout,调大 settings.yaml shell.maxTimeoutMs,或改用 wait=false 后台模式(无超时)",
|
|
24
|
+
execAborted: "ocr 执行被中止",
|
|
25
|
+
execNotFound: "ocr 命令不存在:请先 `brew install open-code-review` 或 `npm i -g @alibaba-group/open-code-review`",
|
|
26
|
+
execFailed: "ocr 执行失败(exit={exitCode})",
|
|
27
|
+
detailSeparator: ":",
|
|
28
|
+
stdoutTruncated: "[ocr-review] 注意:输出超过 {limit} 字节上限已截断{spill}",
|
|
29
|
+
outputSpill: "(完整输出已落盘:{path})",
|
|
30
|
+
backgroundPolling: "稍候用 ocr_session(action=list) 找本仓库最新会话(启动几秒后出现,按 scanPaths/时间匹配);completed_files == selected_files 即已完成;再 ocr_session(action=comments, id) 取全部评论、ocr_session(action=show, id) 看元数据与逐文件检查点。后台运行不设宿主超时,只在 2 小时后兜底回收(挂死不动会一直占着官方作业桶),取消本工具调用即终止该后台 OCR(宿主 kill + 命令内回收守护)。",
|
|
31
|
+
jobReasonAborted: "ocr-review 工具调用被中止",
|
|
32
|
+
jobReasonTimeout: "ocr-review 后台评审超过 2 小时,兜底回收",
|
|
33
|
+
jobReasonUnload: "ocr-review 插件卸载",
|
|
34
|
+
jobOutputGap: "\n[ocr-review] 注意:有一次输出读取失败,已按原游标重试(这段可能不完整)",
|
|
35
|
+
requiredNotArray: "parameters.required 必须是字符串数组",
|
|
36
|
+
requiredItemNotString: "parameters.required 元素必须是字符串",
|
|
37
|
+
argsMustBeObject: "ocr {name}: 入参必须是 JSON 对象(收到 {received})",
|
|
38
|
+
missingRequiredArgs: "ocr {name}: 缺少必填参数 {keys}(缺失即拒绝,不产生副作用)",
|
|
39
|
+
bodyMustBeObject: "body 必须是 JSON 对象",
|
|
40
|
+
outputFileMissing: "ocr 未写出 --output 文件({path}):{cause}——请确认 ocr 版本支持 --output,或用 ocr_session 查询会话结果",
|
|
41
|
+
settingsServiceGone: "{ns} 所在的设置服务在读取过程中已不可用",
|
|
42
|
+
repoRequired: "repo 必填,且为 Git 仓库绝对路径",
|
|
43
|
+
repoRequiredUnknownWorkspace: "repo 必填:当前会话工作区未知,请显式传入 Git 仓库绝对路径",
|
|
44
|
+
repoMustBeAbsolute: "repo 必须是绝对路径(以 / 开头):{root}",
|
|
45
|
+
noNulBytes: "{name} 不能包含 NUL 字节",
|
|
46
|
+
mustBeString: "{name} 必须是字符串(收到 {received})",
|
|
47
|
+
listItemsMustBeString: "{name} 数组元素必须是字符串",
|
|
48
|
+
mustBeStringOrList: "{name} 必须是字符串或字符串数组",
|
|
49
|
+
intFlagMustBeInt: "{flag} 需要为 ≥{min} 的整数,收到 {received}",
|
|
50
|
+
scopeBranchCommitExclusive: "scope=branch/from/to 与 commit 互斥(一次只能选一种审查范围)",
|
|
51
|
+
branchNeedsFromAndTo: "scope=branch 需要同时提供 from 与 to",
|
|
52
|
+
commitNeedsValue: "scope=commit 需要 commit 参数",
|
|
53
|
+
unknownScope: "未知 scope:{scope}(workspace/commit/branch)",
|
|
54
|
+
pathsRequired: "paths 必填:至少传一个待审查文件的仓库相对路径",
|
|
55
|
+
invalidAction: "action 非法:{action}(期望 {expected})",
|
|
56
|
+
resultSchemaViolation: "ocr {tool}: 返回值不符合该工具的 output schema:{violations}",
|
|
57
|
+
resultSchemaUnsupported: "ocr {tool}: 返回形态守卫不支持 output schema 关键字 {keyword}(插件内 bug:lib/output.ts 的关键字子集没跟上 schema)",
|
|
58
|
+
ocrNotJson: "OCR 未返回合法 JSON 输出",
|
|
59
|
+
ocrNotObject: "OCR JSON 输出不是对象",
|
|
60
|
+
ocrStatusInvalid: "OCR 输出 status 非法或缺失:{status}(期望 {expected});exit 0 不代表评审完成,请查 stderr 与 ~/.opencodereview/sessions/",
|
|
61
|
+
statusNone: "(无)",
|
|
62
|
+
ocrCommentsNotArray: "OCR 输出 comments 字段不是数组,无法确认评论完整性",
|
|
63
|
+
warningSeparator: ":",
|
|
64
|
+
reviewSummaryHint: "完整评论与逐文件失败详情请用 `ocr session show <session_id>` 查看;start_line=0 表示 OCR 未能锚定行号,可依据 suggestion/existing_code 在文件中定位。判读口径:droppedCount 与 invalidCommentCount 同时为 0 时本摘要才覆盖 OCR 的全部产出;否则请调大 maxComments(或置 0 = 不截断)后复核,切勿把「本摘要无 critical」当作「代码干净」。",
|
|
65
|
+
lineZeroNote: "(line 0: 定位失败,按 existing_code 手动定位)",
|
|
66
|
+
parseFailedWithReason: "{tool} 输出解析失败:{message}\n原始内容前 {limit} 码元(已脱敏):\n{raw}",
|
|
67
|
+
parseFailedPlain: "{tool} 输出解析失败\n原始内容前 {limit} 码元(已脱敏):\n{raw}",
|
|
68
|
+
sourceSettingsUnavailable: "dsh 设置服务(ctx.settings)未装配:本插件需要它读取已配置的 provider 列表",
|
|
69
|
+
sourceSettingsFailed: "调用 dsh 设置服务失败:{message}",
|
|
70
|
+
sourceNamespaceUnavailable: "dsh 配置里没有 {ns} 命名空间:未安装 pi-ai 适配器,或还没配置任何 provider",
|
|
71
|
+
sourceCredentialsUnavailable: "dsh 凭据服务(ctx.credentials)未装配:无法确认 provider 的 API key 是否已配置",
|
|
72
|
+
badEnvKey: "非法 env key:{key}",
|
|
73
|
+
configNotJson: "{where} 不是合法 JSON,已中止写入以保护既有配置(请先修复或删掉该文件):{cause}",
|
|
74
|
+
configNotObject: "{where} 顶层不是 JSON 对象,已中止写入以保护既有配置",
|
|
75
|
+
providerNotInList: "provider 不在 dsh 配置的 {ns}.providers 列表:{provider}",
|
|
76
|
+
modelNotInList: "model 不在 {provider} 的 models 列表中:{model}",
|
|
77
|
+
ocrConfigPathInvalid: "设置项 ocrConfigPath 必须是绝对路径或 ~ 开头的路径:{path}",
|
|
78
|
+
noApiKeyEnv: "provider {provider} 未声明 apiKeyEnv,无法动态读取 key(OCR 自定义 provider 不支持无 key 解析)",
|
|
79
|
+
keyNotResolvable: "provider {provider} 的 API key 未配置:dsh 凭据服务未解析到 {ref}",
|
|
80
|
+
credScriptMissing: "get-cred 脚本缺失:{path}(api_key_cmd 无法工作)",
|
|
81
|
+
providerModelRequired: "provider 与 model 必填",
|
|
82
|
+
bodyParseFailed: "body 解析失败:{message}",
|
|
83
|
+
reviewToolDescription: "用 open-code-review(ocr)对 Git 变更做深度学习代码审查:OCR 调用其自身配置的 LLM(provider/model 在 ocr-review 设置卡选择)独立审查,返回按严重度/类别聚合的评论摘要。scope=workspace 审工作区 staged+unstaged+untracked 变更(默认);scope=commit 审单个提交;scope=branch 用从/到分支对比(merge-base)。常用先 ocr_delegate_preview 看范围。effort 控制深度(low=1 轮/medium=2/high=3,越深越贵)。background 提供需求/业务上下文能显著提升质量。首次审查前确保 ocr 已配置且 'ocr llm test' 通过。",
|
|
84
|
+
scanToolDescription: "用 open-code-review(ocr)做全文件扫描审查(无需 Git 变更/无 diff,适合审计陌生代码库或指定目录)。--path 逗号分隔的仓库相对目录/文件(缺省整个仓库)。与 ocr_review 一样由 OCR 自身 LLM 独立审查,返回聚合评论摘要。",
|
|
85
|
+
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)。",
|
|
86
|
+
ruleToolDescription: "open-code-review 委托模式第 2 步:按文件路径返回适配的审查规则(按规则内容分组,共享规则的多个文件归一组)。把 ocr_delegate_preview 得到的 reviewable 路径传进来,作为你自己审查时的检查清单。规则文本较长时按批传入。",
|
|
87
|
+
sessionToolDescription: "查询 open-code-review 的历史评审会话:action=list 列最近会话(含 session_id/mode/model/评论数,可用于 ocr_review --resume 恢复中断的评审);action=show 查看单个会话元数据与逐文件检查点;action=comments 取单个会话的评论明细。session_id 也可从 ocr_review/ocr_scan 的返回结果直接获取。",
|
|
88
|
+
paramRepo: "Git 仓库绝对路径(可选:缺省=当前会话工作区)",
|
|
89
|
+
paramScope: "审查范围(缺省 workspace)",
|
|
90
|
+
paramCommit: "scope=commit 时必填:提交哈希",
|
|
91
|
+
paramFrom: "scope=branch 时必填:对比起点 ref(如 main)",
|
|
92
|
+
paramTo: "scope=branch 时必填:对比终点 ref(如 feature/x)",
|
|
93
|
+
paramCommitShort: "scope=commit 时必填",
|
|
94
|
+
paramFromShort: "scope=branch 时必填",
|
|
95
|
+
paramToShort: "scope=branch 时必填",
|
|
96
|
+
paramEffort: "投入档位(缺省 medium)",
|
|
97
|
+
paramBackground: "需求/业务上下文(官方 skill 推荐:调用前先用 git log/diff/提交信息快速分析变更意图,提取 3-5 句业务背景传入,是提升评审质量最有效的参数;从其它 agent 调用时始终携带 PR 描述/需求)",
|
|
98
|
+
paramBackgroundShort: "需求/业务上下文",
|
|
99
|
+
paramBackgroundPreview: "业务上下文(会出现在输出中供审查参考)",
|
|
100
|
+
paramExclude: "gitignore 风格排除模式(可多值)",
|
|
101
|
+
paramExcludeShort: "排除模式",
|
|
102
|
+
paramWait: "true(缺省)=等待完成并返回聚合摘要;false=后台启动立即返回(2 小时后兜底回收),用 ocr_session 轮询结果。两种模式都带宿主回收守护:dsh 退出(含 kill -9)时 OCR 进程一并结束",
|
|
103
|
+
paramProvider: "本次运行临时指定已配置 provider",
|
|
104
|
+
paramModel: "本次运行临时覆盖 model",
|
|
105
|
+
paramResume: "从之前中断的会话 id 恢复(会话 id 在返回结果里)",
|
|
106
|
+
paramScanPath: "仓库相对目录或文件(缺省整仓,可多值)",
|
|
107
|
+
paramBatch: "分批策略",
|
|
108
|
+
paramPaths: "待查询的仓库相对文件路径(至少 1 个,最多 200)",
|
|
109
|
+
paramSessionAction: "list:列最近会话(缺省)/ show:查看单个 / comments:取评论",
|
|
110
|
+
paramSessionId: "action=show/comments 时的会话 id",
|
|
111
|
+
paramSessionLimit: "list 上限 1-100(缺省 10)",
|
|
112
|
+
unitGroup: "组",
|
|
113
|
+
unitFile: "文件",
|
|
114
|
+
tuningConcurrency: "并发{unit}数(OCR --concurrency;缺省不传=OCR 原生 8)",
|
|
115
|
+
tuningTimeout: "每{unit}任务超时分钟(OCR --timeout;0=不限时;缺省不传=OCR 原生 15)",
|
|
116
|
+
tuningMaxTools: "每{unit}工具调用轮数上限(OCR --max-tools;0=模板默认;缺省不传=模板默认)",
|
|
117
|
+
tuningMaxTokens: "每{unit}提示词 token 上限(OCR --max-tokens;0=配置/模板默认;缺省不传=模板默认)",
|
|
118
|
+
tuningMaxTokensBudget: "本次运行 total token(input+output)预算闸门,超出即停止派发部分结果(OCR --max-tokens-budget;0=不限;缺省不传=不限)",
|
|
119
|
+
previewHint: "对每个 reviewable 文件:用 git diff <merge_base>..<to> -- <path>(branch)/git show <commit> -- <path>(commit)/ git diff HEAD -- <path> 或直接读文件(workspace 新文件)取 diff。",
|
|
120
|
+
ruleHint: "按分组规则逐个审查对应文件,输出评论建议格式:{path, content, start_line, end_line, category, severity}。",
|
|
121
|
+
sessionEmpty: "(暂无历史评审会话:先跑一次 ocr_review/ocr_scan 再查询)",
|
|
122
|
+
sessionTruncated: "…[截断] 完整内容见 ~/.opencodereview/sessions/",
|
|
123
|
+
sessionResumeHint: "要恢复中断的区间/commit 评审:ocr_review ... --resume <session_id>(工作区评审不支持恢复)。",
|
|
124
|
+
routingText: `
|
|
125
|
+
- 用户要求「审查代码/Review/检查我的改动/审这个 commit/PR」时,用 ocr 工具做代码审查:
|
|
126
|
+
- ocr_review:OCR 用自己配置的 LLM 独立审查工作区/commit/分支变更(effort 控制深度,可给 background 上下文)。
|
|
127
|
+
- ocr_scan:对无 git 历史的目录或指定路径做全文件审查。
|
|
128
|
+
- ocr_delegate_preview / ocr_delegate_rule:只取「文件清单 + 适配规则」的确定性脚手架(OCR 端零 LLM 消耗),
|
|
129
|
+
配合 git diff 由你自己完成审查——适合复用本会话模型能力、或先确认审查范围再决定是否跑 ocr_review。
|
|
130
|
+
- 审查前若不确定范围,先 ocr_delegate_preview(不花 token);范围过大时用 exclude 收敛或按 commit 拆。
|
|
131
|
+
- OCR 端点/provider/model 由「设置 → 插件 → ocr-review 卡片」从 dsh 的 settings.yaml providers 选择;
|
|
132
|
+
也可在工具参数里用 --provider/--model 临时覆盖。
|
|
133
|
+
- 调用 ocr_review / ocr_scan 前,先用 git log/diff/提交信息快速分析变更意图,提取 3-5 句业务上下文
|
|
134
|
+
传入 background(open-code-review 官方 skill 明确推荐"始终携带",是质量提升最有效的单一参数)。
|
|
135
|
+
- 若 ocr_review/ocr_scan 输出含 "No tool calls parsed" 或大量 "Max tool requests reached",
|
|
136
|
+
是当前模型的工具调用能力问题——建议在 ocr-review 卡片换一个支持原生工具调用的模型(sensenova、deepseek、glm、qwen 系),
|
|
137
|
+
不要反复重试同一个模型。
|
|
138
|
+
- LLM 调用本身有重试:OCR 内置重试(429/408/409 与 5xx 自动退避重试);per-file 组失败被隔离并进 warnings,不整体重跑。
|
|
139
|
+
工具层超时(10 分钟)或部分文件失败后,可 ocr_session 查 session_id、用 ocr_review --resume 恢复中断的区间/commit 评审。
|
|
140
|
+
`.trim()
|
|
141
|
+
},
|
|
142
|
+
en: {
|
|
143
|
+
middleEllipsis: " …[middle omitted]… ",
|
|
144
|
+
execTimedOut: "ocr timed out (effective {minutes} min = min(request, shell.maxTimeoutMs)): narrow the review scope (exclude / --commit / a smaller diff), lower OCR's per-group --timeout, raise shell.maxTimeoutMs in settings.yaml, or switch to background mode with wait=false (no timeout)",
|
|
145
|
+
execAborted: "ocr execution aborted",
|
|
146
|
+
execNotFound: "ocr command not found: install it first with `brew install open-code-review` or `npm i -g @alibaba-group/open-code-review`",
|
|
147
|
+
execFailed: "ocr failed (exit={exitCode})",
|
|
148
|
+
detailSeparator: ": ",
|
|
149
|
+
stdoutTruncated: "[ocr-review] note: output exceeded the {limit}-byte cap and was truncated{spill}",
|
|
150
|
+
outputSpill: " (full output spilled to {path})",
|
|
151
|
+
backgroundPolling: "Look up this repository's newest session shortly with ocr_session(action=list) (it appears a few seconds after start; match by scanPaths/time); completed_files == selected_files means it is done. Then ocr_session(action=comments, id) for all comments and ocr_session(action=show, id) for metadata and per-file checkpoints. Background runs get reclaimed by a 2-hour backstop (a wedged one would otherwise keep occupying the official job bucket); cancelling this tool call terminates the background OCR (host kill + the reaper guard inside the command).",
|
|
152
|
+
jobReasonAborted: "ocr-review tool call aborted",
|
|
153
|
+
jobReasonTimeout: "ocr-review background review exceeded 2 hours and was reclaimed",
|
|
154
|
+
jobReasonUnload: "ocr-review plugin unloaded",
|
|
155
|
+
jobOutputGap: "\n[ocr-review] note: one output read failed and was retried from the same offset (this stretch may be incomplete)",
|
|
156
|
+
requiredNotArray: "parameters.required must be an array of strings",
|
|
157
|
+
requiredItemNotString: "parameters.required items must be strings",
|
|
158
|
+
argsMustBeObject: "ocr {name}: arguments must be a JSON object (got {received})",
|
|
159
|
+
missingRequiredArgs: "ocr {name}: missing required parameter(s) {keys} (rejected outright, no side effects)",
|
|
160
|
+
bodyMustBeObject: "body must be a JSON object",
|
|
161
|
+
outputFileMissing: "ocr did not write the --output file ({path}): {cause} — check that your ocr version supports --output, or query the session with ocr_session",
|
|
162
|
+
settingsServiceGone: "the settings service holding {ns} became unavailable while being read",
|
|
163
|
+
repoRequired: "repo is required and must be the absolute path of a Git repository",
|
|
164
|
+
repoRequiredUnknownWorkspace: "repo is required: the current session workspace is unknown, pass the absolute path of a Git repository explicitly",
|
|
165
|
+
repoMustBeAbsolute: "repo must be an absolute path (starting with /): {root}",
|
|
166
|
+
noNulBytes: "{name} must not contain NUL bytes",
|
|
167
|
+
mustBeString: "{name} must be a string (got {received})",
|
|
168
|
+
listItemsMustBeString: "the items of {name} must be strings",
|
|
169
|
+
mustBeStringOrList: "{name} must be a string or an array of strings",
|
|
170
|
+
intFlagMustBeInt: "{flag} must be an integer ≥ {min}, got {received}",
|
|
171
|
+
scopeBranchCommitExclusive: "scope=branch/from/to and commit are mutually exclusive (only one review scope at a time)",
|
|
172
|
+
branchNeedsFromAndTo: "scope=branch requires both from and to",
|
|
173
|
+
commitNeedsValue: "scope=commit requires the commit parameter",
|
|
174
|
+
unknownScope: "unknown scope: {scope} (workspace/commit/branch)",
|
|
175
|
+
pathsRequired: "paths is required: pass at least one repo-relative path of a file to review",
|
|
176
|
+
invalidAction: "invalid action: {action} (expected {expected})",
|
|
177
|
+
resultSchemaViolation: "ocr {tool}: the tool result does not match its output schema: {violations}",
|
|
178
|
+
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)",
|
|
179
|
+
ocrNotJson: "OCR returned no valid JSON output",
|
|
180
|
+
ocrNotObject: "the OCR JSON output is not an object",
|
|
181
|
+
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/",
|
|
182
|
+
statusNone: "(none)",
|
|
183
|
+
ocrCommentsNotArray: "the comments field of the OCR output is not an array, comment completeness cannot be confirmed",
|
|
184
|
+
warningSeparator: ": ",
|
|
185
|
+
reviewSummaryHint: "Use `ocr session show <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. How to read this summary: only when droppedCount and invalidCommentCount are both 0 does it cover everything OCR produced; otherwise raise maxComments (or set 0 = no truncation) and re-check — never treat \"no critical in this summary\" as \"the code is clean\".",
|
|
186
|
+
lineZeroNote: "(line 0: not anchored, locate it via existing_code)",
|
|
187
|
+
parseFailedWithReason: "{tool} output parsing failed: {message}\nfirst {limit} UTF-16 code units of the raw output (redacted):\n{raw}",
|
|
188
|
+
parseFailedPlain: "{tool} output parsing failed\nfirst {limit} UTF-16 code units of the raw output (redacted):\n{raw}",
|
|
189
|
+
sourceSettingsUnavailable: "the dsh settings service (ctx.settings) is not wired up: this plugin needs it to read the configured provider list",
|
|
190
|
+
sourceSettingsFailed: "calling the dsh settings service failed: {message}",
|
|
191
|
+
sourceNamespaceUnavailable: "the dsh configuration has no {ns} namespace: the pi-ai adapter is not installed, or no provider is configured yet",
|
|
192
|
+
sourceCredentialsUnavailable: "the dsh credentials service (ctx.credentials) is not wired up: cannot confirm whether a provider's API key is configured",
|
|
193
|
+
badEnvKey: "invalid env key: {key}",
|
|
194
|
+
configNotJson: "{where} is not valid JSON; the write was aborted to protect the existing configuration (fix or delete that file first): {cause}",
|
|
195
|
+
configNotObject: "the top level of {where} is not a JSON object; the write was aborted to protect the existing configuration",
|
|
196
|
+
providerNotInList: "provider is not in the {ns}.providers list configured by dsh: {provider}",
|
|
197
|
+
modelNotInList: "model is not in the models list of {provider}: {model}",
|
|
198
|
+
ocrConfigPathInvalid: "the ocrConfigPath setting must be absolute or start with ~: {path}",
|
|
199
|
+
noApiKeyEnv: "provider {provider} declares no apiKeyEnv, so the key cannot be read dynamically (OCR custom providers do not support keyless resolution)",
|
|
200
|
+
keyNotResolvable: "the API key of provider {provider} is not configured: the dsh credentials service resolved no {ref}",
|
|
201
|
+
credScriptMissing: "get-cred script missing: {path} (api_key_cmd cannot work)",
|
|
202
|
+
providerModelRequired: "provider and model are both required",
|
|
203
|
+
bodyParseFailed: "failed to parse the body: {message}",
|
|
204
|
+
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.",
|
|
205
|
+
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.",
|
|
206
|
+
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).",
|
|
207
|
+
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.",
|
|
208
|
+
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.",
|
|
209
|
+
paramRepo: "Absolute path to the Git repository (optional: default = current session workspace)",
|
|
210
|
+
paramScope: "Review scope (default workspace)",
|
|
211
|
+
paramCommit: "Required with scope=commit: commit hash",
|
|
212
|
+
paramFrom: "Required with scope=branch: comparison start ref (e.g. main)",
|
|
213
|
+
paramTo: "Required with scope=branch: comparison end ref (e.g. feature/x)",
|
|
214
|
+
paramCommitShort: "Required with scope=commit",
|
|
215
|
+
paramFromShort: "Required with scope=branch",
|
|
216
|
+
paramToShort: "Required with scope=branch",
|
|
217
|
+
paramEffort: "Effort level (default medium)",
|
|
218
|
+
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)",
|
|
219
|
+
paramBackgroundShort: "Requirements/business context",
|
|
220
|
+
paramBackgroundPreview: "Business context (it shows up in the output for review reference)",
|
|
221
|
+
paramExclude: "gitignore-style exclusion patterns (multiple allowed)",
|
|
222
|
+
paramExcludeShort: "Exclusion patterns",
|
|
223
|
+
paramWait: "true (default) = wait for completion and return the aggregated summary; false = start in the background and return immediately (a 2-hour backstop reclaims it), poll with ocr_session. Both modes carry the host reaper guard: when dsh exits (including kill -9) the OCR process is terminated too",
|
|
224
|
+
paramProvider: "Temporarily use a configured provider for this run",
|
|
225
|
+
paramModel: "Temporarily override the model for this run",
|
|
226
|
+
paramResume: "Resume from a previously interrupted session id (the id is in the result)",
|
|
227
|
+
paramScanPath: "Repo-relative directories or files (default: whole repo, multiple allowed)",
|
|
228
|
+
paramBatch: "Batching strategy",
|
|
229
|
+
paramPaths: "Repo-relative file paths to look up (at least 1, at most 200)",
|
|
230
|
+
paramSessionAction: "list: recent sessions (default) / show: one session / comments: comment list",
|
|
231
|
+
paramSessionId: "Session id for action=show/comments",
|
|
232
|
+
paramSessionLimit: "list cap 1-100 (default 10)",
|
|
233
|
+
unitGroup: "group",
|
|
234
|
+
unitFile: "file",
|
|
235
|
+
tuningConcurrency: "Concurrent {unit} count (OCR --concurrency; unset = OCR native 8)",
|
|
236
|
+
tuningTimeout: "Per-{unit} task timeout in minutes (OCR --timeout; 0 = unlimited; unset = OCR native 15)",
|
|
237
|
+
tuningMaxTools: "Per-{unit} tool-call round cap (OCR --max-tools; 0 = template default; unset = template default)",
|
|
238
|
+
tuningMaxTokens: "Per-{unit} prompt token cap (OCR --max-tokens; 0 = config/template default; unset = template default)",
|
|
239
|
+
tuningMaxTokensBudget: "Total token (input+output) budget gate for this run; dispatch stops once exceeded (OCR --max-tokens-budget; 0 = unlimited; unset = unlimited)",
|
|
240
|
+
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).",
|
|
241
|
+
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}.",
|
|
242
|
+
sessionEmpty: "(no review sessions yet: run ocr_review/ocr_scan once before querying)",
|
|
243
|
+
sessionTruncated: "…[truncated] full content in ~/.opencodereview/sessions/",
|
|
244
|
+
sessionResumeHint: "To resume an interrupted range/commit review: ocr_review ... --resume <session_id> (workspace reviews cannot be resumed).",
|
|
245
|
+
routingText: `
|
|
246
|
+
- When the user asks to "review the code / Review / check my changes / review this commit or PR", use the ocr tools:
|
|
247
|
+
- ocr_review: OCR reviews workspace/commit/branch changes with its own configured LLM (effort controls depth, background carries context).
|
|
248
|
+
- ocr_scan: whole-file review of directories without git history, or of given paths.
|
|
249
|
+
- ocr_delegate_preview / ocr_delegate_rule: deterministic scaffolding that only returns "file list + fitting rules"
|
|
250
|
+
(zero LLM cost on the OCR side); pair it with git diff and review yourself — good for reusing this session's model,
|
|
251
|
+
or for confirming the scope before deciding to run ocr_review.
|
|
252
|
+
- Unsure about the scope? Run ocr_delegate_preview first (costs no tokens); if the scope is huge, narrow it with
|
|
253
|
+
exclude or split by commit.
|
|
254
|
+
- The OCR endpoint/provider/model comes from "Settings → Plugins → ocr-review card", fed by dsh's settings.yaml
|
|
255
|
+
providers; --provider/--model can override per call.
|
|
256
|
+
- Before ocr_review / ocr_scan, analyse the change intent via git log/diff/commit messages and pass 3-5 sentences of
|
|
257
|
+
business context as background (the official open-code-review skill says "always carry it" — the single most
|
|
258
|
+
effective quality knob).
|
|
259
|
+
- If ocr_review/ocr_scan output contains "No tool calls parsed" or many "Max tool requests reached", the current
|
|
260
|
+
model's tool-calling ability is the problem — switch to a model with native tool calls in the ocr-review card
|
|
261
|
+
(sensenova, deepseek, glm, qwen families) instead of retrying the same one.
|
|
262
|
+
- LLM calls retry themselves: OCR has built-in backoff retries (429/408/409 and 5xx); per-file group failures are
|
|
263
|
+
isolated and land in warnings instead of re-running everything. After a tool-layer timeout (10 minutes) or partial
|
|
264
|
+
file failures, use ocr_session to find the session_id and ocr_review --resume to continue an interrupted
|
|
265
|
+
range/commit review.
|
|
266
|
+
`.trim()
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
/** 花括号占位符(键限 \w+;`{path, content}` 这类字面花括号不匹配)。 */
|
|
270
|
+
const PLACEHOLDER = /\{(?<key>\w+)\}/gu;
|
|
271
|
+
/**
|
|
272
|
+
* host 半的 `{name}` 插值:与卡片侧官方 Translate 同语法(宿主那份实现在
|
|
273
|
+
* dsh-client-locale 里,host 侧没有 i18n 面,只能自带这一小段)。
|
|
274
|
+
* @param template - 带 `{name}` 占位符的文案模板。
|
|
275
|
+
* @param params - 占位符 → 已字符串化的值;缺项替换为空串(不抛)。
|
|
276
|
+
* @returns 插值后的文本。
|
|
277
|
+
*/
|
|
278
|
+
function format(template, params) {
|
|
279
|
+
return template.replaceAll(PLACEHOLDER, (_all, key) => params[key] ?? "");
|
|
280
|
+
}
|
|
281
|
+
//#endregion
|
|
282
|
+
//#region lib/output.ts
|
|
283
|
+
/** 守卫支持的 schema 关键字(官方 enforced subset 的全部关键字,见
|
|
284
|
+
* installed dsh-tools lib/index.js:206;description/title/default/examples 是
|
|
285
|
+
* 官方同样忽略的注解)。 */
|
|
286
|
+
const SUPPORTED_KEYWORDS = /* @__PURE__ */ new Set([
|
|
287
|
+
"type",
|
|
288
|
+
"oneOf",
|
|
289
|
+
"properties",
|
|
290
|
+
"required",
|
|
291
|
+
"additionalProperties",
|
|
292
|
+
"items",
|
|
293
|
+
"const",
|
|
294
|
+
"enum",
|
|
295
|
+
"description",
|
|
296
|
+
"title",
|
|
297
|
+
"default",
|
|
298
|
+
"examples"
|
|
299
|
+
]);
|
|
300
|
+
/** schema 里的关键字必须全部落在守卫支持的子集内(递归查 oneOf/properties/items)。
|
|
301
|
+
* 违例抛错而不是当违规值报:schema 是插件自己的代码,错的是代码不是模型的数据。 */
|
|
302
|
+
function assertGuardable(schema, toolName, messages) {
|
|
303
|
+
for (const keyword of Object.keys(schema)) if (!SUPPORTED_KEYWORDS.has(keyword)) throw new Error(format(messages.resultSchemaUnsupported, {
|
|
304
|
+
tool: toolName,
|
|
305
|
+
keyword
|
|
306
|
+
}));
|
|
307
|
+
for (const branch of schema.oneOf ?? []) assertGuardable(branch, toolName, messages);
|
|
308
|
+
for (const property of Object.values(schema.properties ?? {})) assertGuardable(property, toolName, messages);
|
|
309
|
+
const { items } = schema;
|
|
310
|
+
if (items !== void 0) assertGuardable(items, toolName, messages);
|
|
311
|
+
}
|
|
312
|
+
/** 标量分支的形态判定(true = 不合形态;判据对齐官方 checkValue 的标量臂:
|
|
313
|
+
* number 须为有限 JSON 数、integer 须整数值)。 */
|
|
314
|
+
function scalarMismatch(type, value) {
|
|
315
|
+
if (type === "string") return typeof value !== "string";
|
|
316
|
+
if (type === "number") return typeof value !== "number" || !Number.isFinite(value);
|
|
317
|
+
if (type === "integer") return typeof value !== "number" || !Number.isInteger(value);
|
|
318
|
+
if (type === "boolean") return typeof value !== "boolean";
|
|
319
|
+
return value !== null;
|
|
320
|
+
}
|
|
321
|
+
/** object 分支:isRecord → additionalProperties:false 的多余键 → required(缺键与
|
|
322
|
+
* 「键在但值是 undefined」同判,与官方一致)→ 逐声明字段递归。 */
|
|
323
|
+
function objectViolations(schema, value, path, walk) {
|
|
324
|
+
if (!isRecord(value)) return [`${path}: expected object`];
|
|
325
|
+
const { properties, required, additionalProperties } = schema;
|
|
326
|
+
if (additionalProperties === false) {
|
|
327
|
+
const extra = Object.keys(value).filter((key) => properties?.[key] === void 0);
|
|
328
|
+
if (extra.length > 0) return [`${path}: fields outside the schema: ${extra.join(", ")}`];
|
|
329
|
+
}
|
|
330
|
+
for (const key of required ?? []) if (!(key in value) || value[key] === void 0) return [`${path}: missing required field ${key}`];
|
|
331
|
+
const violations = [];
|
|
332
|
+
for (const key of Object.keys(value)) {
|
|
333
|
+
const child = properties?.[key];
|
|
334
|
+
if (child !== void 0) violations.push(...walk(child, value[key], `${path}.${key}`));
|
|
335
|
+
}
|
|
336
|
+
return violations;
|
|
337
|
+
}
|
|
338
|
+
/** array 分支:Array.isArray → 无 items 视为任意元素 → 逐元素递归(违规一次列全)。 */
|
|
339
|
+
function arrayViolations(schema, value, path, walk) {
|
|
340
|
+
if (!Array.isArray(value)) return [`${path}: expected array`];
|
|
341
|
+
const { items } = schema;
|
|
342
|
+
if (items === void 0) return [];
|
|
343
|
+
return value.flatMap((item, index) => walk(items, item, `${path}[${index}]`));
|
|
344
|
+
}
|
|
345
|
+
/** 官方子集的迷你走查(path 限定违规位置,一次列全)。oneOf 语义与官方一致:
|
|
346
|
+
* 恰好一支命中,多支/零支都算违规。 */
|
|
347
|
+
function violationsOf(schema, value, path) {
|
|
348
|
+
if (schema.oneOf !== void 0) {
|
|
349
|
+
const passing = schema.oneOf.filter((branch) => violationsOf(branch, value, path).length === 0);
|
|
350
|
+
if (passing.length !== 1) return [`${path}: matched ${passing.length} of ${schema.oneOf.length} oneOf branches`];
|
|
351
|
+
return [];
|
|
352
|
+
}
|
|
353
|
+
const { type } = schema;
|
|
354
|
+
if (type === "object") return objectViolations(schema, value, path, violationsOf);
|
|
355
|
+
if (type === "array") return arrayViolations(schema, value, path, violationsOf);
|
|
356
|
+
if (type === void 0) return [];
|
|
357
|
+
if (scalarMismatch(type, value)) return [`${path}: expected ${type}`];
|
|
358
|
+
if (schema.const !== void 0 && value !== schema.const) return [`${path}: must be exactly ${JSON.stringify(schema.const)}`];
|
|
359
|
+
if (schema.enum !== void 0 && !schema.enum.includes(value)) return [`${path}: must be one of ${schema.enum.map((entry) => JSON.stringify(entry)).join("/")}`];
|
|
360
|
+
return [];
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* 校验并放行工具返回值:必须命中该工具自己的 output schema。
|
|
364
|
+
* 违规抛错而非静默裁剪——静默修正是第二个 bug 的开始,抛错让回归在开发期
|
|
365
|
+
* (vitest/首次调用)就炸出来。
|
|
366
|
+
* @param schema 该工具注册时声明的 output.schema(与宿主校验用的是同一个对象)
|
|
367
|
+
* @param value execute 交回的 canonical 值(结构化对象 / 数组 / 标量 / null)
|
|
368
|
+
* @param toolName 工具名(违规信息要点名,模型才知道该重试哪一次调用)
|
|
369
|
+
* @param messages 双语文案
|
|
370
|
+
* @returns 原值(校验只读不改,值原样交给宿主)
|
|
371
|
+
*/
|
|
372
|
+
function assertToolOutput(schema, value, toolName, messages) {
|
|
373
|
+
assertGuardable(schema, toolName, messages);
|
|
374
|
+
const violations = violationsOf(schema, value, "result");
|
|
375
|
+
if (violations.length > 0) throw new Error(format(messages.resultSchemaViolation, {
|
|
376
|
+
tool: toolName,
|
|
377
|
+
violations: violations.join("; ")
|
|
378
|
+
}));
|
|
379
|
+
return value;
|
|
380
|
+
}
|
|
381
|
+
/** provider key 形状:`sk-…`/`ark-…`/`ghp-…` 一类「短前缀 + 长随机串」。
|
|
382
|
+
*
|
|
383
|
+
* 为什么额外要求随机串里至少有一个数字或大写字母:真实 key 是随机 base62(32 位
|
|
384
|
+
* 纯小写的概率约 1e-12),而审查输出里的 kebab 小写长串几乎只可能是文件路径
|
|
385
|
+
* (`src/my-super-long-directory-name/index.ts`)。少了这道约束,session/delegate
|
|
386
|
+
* 透传出的路径会被抹成 ***REDACTED***,模型拿不到可行动的位置信息。 */
|
|
387
|
+
const STANDALONE_KEY = /\b[A-Za-z]{2,5}-(?=[A-Za-z0-9_-]{20,}\b)(?=[A-Za-z0-9_-]*[0-9A-Z])[A-Za-z0-9_-]{20,}\b/gu;
|
|
388
|
+
/** `Authorization: Bearer xxx` / `bearer xxx`。
|
|
389
|
+
*
|
|
390
|
+
* 字符集写 `[a-z…]` 而不是 `[A-Za-z…]`:本条带 `i` 旗标,`a-z` 在大小写不敏感下
|
|
391
|
+
* 已同时匹配 `A-Z`——再并列一枚 `A-Z` 是同一个字符集抄两遍(sonarjs 判为字符类内重复),
|
|
392
|
+
* 删掉重复的那一位,匹配语言不变。 */
|
|
393
|
+
const BEARER_VALUE = /(?<scheme>\bbearer\s+)[a-z0-9._~+/=-]{12,}/giu;
|
|
394
|
+
/** `api_key: "xxx"`、`apiKey=xxx`、`token: xxx`、`x-api-key: xxx` 等赋值形态。 */
|
|
395
|
+
const KEY_ASSIGNMENT = /(?<name>\b(?:api[_-]?key|apikey|access[_-]?key|secret[_-]?key|client[_-]?secret|password|token)["'\s]*[:=]["'\s]*)[^\s"',;)}\]]{6,}/giu;
|
|
396
|
+
const REDACTION = "***REDACTED***";
|
|
397
|
+
/**
|
|
398
|
+
* 抹掉文本里 key 形状的片段。
|
|
399
|
+
*
|
|
400
|
+
* 为什么需要:OCR 会把 provider 配置错误连同请求头/端点整段回显到 stderr,
|
|
401
|
+
* `ocr llm test` 的输出又直通设置卡片;工具错误文本还会被持久化进会话日志
|
|
402
|
+
* (session log),一次回显就等于把明文 key 写进了长期存储。本插件的既定
|
|
403
|
+
* 决策是「key 绝不落盘、绝不过 HTTP」,故所有原始输出出口都必须过这道闸
|
|
404
|
+
* (harness 同方向的红线见 packages/settings/settings/src/redact.ts)。
|
|
405
|
+
*
|
|
406
|
+
* 只做「形态匹配」替换:赋值/Bearer 形态一律从严(宁可多抹),独立 key 形态则
|
|
407
|
+
* 要求随机串特征,避免把要交给模型的文件路径一起抹掉。
|
|
408
|
+
*/
|
|
409
|
+
function redactKeyMaterial(text) {
|
|
410
|
+
return text.replaceAll(BEARER_VALUE, `$<scheme>${REDACTION}`).replaceAll(KEY_ASSIGNMENT, `$<name>${REDACTION}`).replaceAll(STANDALONE_KEY, REDACTION);
|
|
411
|
+
}
|
|
412
|
+
//#endregion
|
|
413
|
+
//#region lib/argv-guard.ts
|
|
414
|
+
/** POSIX sh 单引号转义:' → '\''。把任意字符串安全放进单引号内,防注入。 */
|
|
415
|
+
function shq(value) {
|
|
416
|
+
return `'${value.replaceAll("'", String.raw`'\''`)}'`;
|
|
417
|
+
}
|
|
418
|
+
/** 绝对路径校验(macOS/Linux):非空、以 / 开头、无 NUL 字节。 */
|
|
419
|
+
function assertAbsoluteRoot(root, messages) {
|
|
420
|
+
if (typeof root !== "string" || root.length === 0) throw new Error(messages.repoRequired);
|
|
421
|
+
if (root.includes("\0")) throw new Error(format(messages.noNulBytes, { name: "repo" }));
|
|
422
|
+
if (!root.startsWith("/")) throw new Error(format(messages.repoMustBeAbsolute, { root }));
|
|
423
|
+
return root;
|
|
424
|
+
}
|
|
425
|
+
const EFFORTS = [
|
|
426
|
+
"low",
|
|
427
|
+
"medium",
|
|
428
|
+
"high"
|
|
429
|
+
];
|
|
430
|
+
/** effort 参数:非法值回默认(配置/medium 由调用方决定回退),此处只钳到合法集。 */
|
|
431
|
+
function clampEffort(value, fallback) {
|
|
432
|
+
return EFFORTS.find((level) => level === value) ?? fallback;
|
|
433
|
+
}
|
|
434
|
+
/** 正整数钳制(concurrency/timeout/maxTokens 用,非正整数回 fallback)。 */
|
|
435
|
+
function clampPositiveInt(value, fallback, min, max) {
|
|
436
|
+
if (value === void 0 || value === null) return fallback;
|
|
437
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
438
|
+
if (!Number.isSafeInteger(parsed)) return fallback;
|
|
439
|
+
return Math.min(max, Math.max(min, parsed));
|
|
440
|
+
}
|
|
441
|
+
/** 入参 → 原始项序列(commaList 的第一段:只判「值从哪来」,不判内容)。
|
|
442
|
+
* 数组分支保留逐元素 for-of 而不是 `value.map`:map 会跳过稀疏数组的空洞,
|
|
443
|
+
* 空洞本是「非字符串元素」这一条该拦的形态,跳过就等于静默少一项过滤条件。 */
|
|
444
|
+
function listSourceItems(value, name, messages) {
|
|
445
|
+
if (typeof value === "string") return value.split(",");
|
|
446
|
+
if (Array.isArray(value)) {
|
|
447
|
+
const items = [];
|
|
448
|
+
for (const item of value) {
|
|
449
|
+
if (typeof item !== "string") throw new TypeError(format(messages.listItemsMustBeString, { name }));
|
|
450
|
+
items.push(item);
|
|
451
|
+
}
|
|
452
|
+
return items;
|
|
453
|
+
}
|
|
454
|
+
throw new TypeError(format(messages.mustBeStringOrList, { name }));
|
|
455
|
+
}
|
|
456
|
+
/** 原始项 → 取值(commaList 的第二段:只判内容,NUL 抛错、trim、丢空串,满 limit 即止)。 */
|
|
457
|
+
function takeTrimmedItems(items, name, limit, messages) {
|
|
458
|
+
const out = [];
|
|
459
|
+
for (const item of items) {
|
|
460
|
+
if (item.includes("\0")) throw new Error(format(messages.noNulBytes, { name }));
|
|
461
|
+
const trimmed = item.trim();
|
|
462
|
+
if (trimmed.length > 0) out.push(trimmed);
|
|
463
|
+
if (out.length >= limit) break;
|
|
464
|
+
}
|
|
465
|
+
return out;
|
|
466
|
+
}
|
|
467
|
+
/** 字符串数组 → 逗号拼接(--exclude/--path 契约)。
|
|
468
|
+
* 元素类型严格:`value.map(String)` 会把对象变成 `[object Object]` 静默混入
|
|
469
|
+
* 模式串(过滤条件失效 = 审查范围被扩大),故非字符串元素一律抛错。
|
|
470
|
+
* 防注入由调用点 shq() 兜底。 */
|
|
471
|
+
function commaList(value, name, limit, messages) {
|
|
472
|
+
if (value === void 0 || value === null) return [];
|
|
473
|
+
return takeTrimmedItems(listSourceItems(value, name, messages), name, limit, messages);
|
|
474
|
+
}
|
|
475
|
+
const REVIEW_OUTPUT_FORMATS = [
|
|
476
|
+
"json",
|
|
477
|
+
"text",
|
|
478
|
+
"sarif"
|
|
479
|
+
];
|
|
480
|
+
/**
|
|
481
|
+
* `--format` 白名单钳制(命令注入防线)。
|
|
482
|
+
*
|
|
483
|
+
* 为什么必须白名单:format 不在任何工具的 parameters schema 里(模型传不进、
|
|
484
|
+
* 但 PTC/直连调用方能传),而宿主对工具入参零校验;构造出的命令串最终会被
|
|
485
|
+
* `wrapWithHostReaper` 塞进 `bash -c '<inner>'`。历史上这里是裸拼接
|
|
486
|
+
* (`parts.push("--format", args.format ?? "json")`),实测
|
|
487
|
+
* `format: "json; touch /tmp/PWNED; echo"` 可在宿主进程内任意执行命令。
|
|
488
|
+
* 转义不是修复——一个「本该只有三个取值的枚举」出现任意字符串就是攻击意图,
|
|
489
|
+
* 一律回落到默认值,绝不进入命令串。
|
|
490
|
+
*/
|
|
491
|
+
function resolveOutputFormat(value) {
|
|
492
|
+
return REVIEW_OUTPUT_FORMATS.find((candidate) => candidate === value) ?? "json";
|
|
493
|
+
}
|
|
494
|
+
//#endregion
|
|
495
|
+
//#region lib/cli.ts
|
|
496
|
+
/** `ocr review|scan|delegate preview` 的背景说明 flag(三处命令构造共用一条)。
|
|
497
|
+
* pushTextFlag 按 `flag.slice(2)` 反推报错用的参数名,故值必须带 `--` 前缀。 */
|
|
498
|
+
const BACKGROUND_FLAG = "--background";
|
|
499
|
+
/** root 解析:显式绝对路径优先;缺失/空串回退到当前会话工作区。 */
|
|
500
|
+
function resolveRoot(value, fallback, messages) {
|
|
501
|
+
if (typeof value === "string" && value.trim().length > 0) return assertAbsoluteRoot(value.trim(), messages);
|
|
502
|
+
if (typeof fallback === "string" && fallback.trim().length > 0) return assertAbsoluteRoot(fallback.trim(), messages);
|
|
503
|
+
throw new Error(messages.repoRequiredUnknownWorkspace);
|
|
504
|
+
}
|
|
505
|
+
/** 报错用的值形态(永不调用可能被劫持的 toJSON/toString)。 */
|
|
506
|
+
function describeValue(value) {
|
|
507
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value);
|
|
508
|
+
return Array.isArray(value) ? "array" : typeof value;
|
|
509
|
+
}
|
|
510
|
+
/** 字符串标量参数:键存在即必须是字符串。
|
|
511
|
+
*
|
|
512
|
+
* 为什么拒绝而不是忽略:宿主对工具入参零校验(dsh tools index.ts
|
|
513
|
+
* `execute(exec.arguments, exec)` 原样下发、schema 只发给模型),错类型一旦
|
|
514
|
+
* 被「当作缺省」就会静默改变语义——实测 `{commit: 12345}` 无 scope 时退化成
|
|
515
|
+
* workspace 审查并把结果当 verdict 报给用户(审查范围被静默扩大)。空串按
|
|
516
|
+
* 缺省处理(与既有语义一致)。 */
|
|
517
|
+
function idValue(value, name, messages) {
|
|
518
|
+
if (value === void 0 || value === null) return "";
|
|
519
|
+
if (typeof value !== "string") throw new TypeError(format(messages.mustBeString, {
|
|
520
|
+
name,
|
|
521
|
+
received: describeValue(value)
|
|
522
|
+
}));
|
|
523
|
+
const trimmed = value.trim();
|
|
524
|
+
if (trimmed.includes("\0")) throw new Error(format(messages.noNulBytes, { name }));
|
|
525
|
+
return trimmed;
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* 数值 flag 忠实透传(桥接层不设钳制——OCR 原生语义:0 = 配置/模板默认或不限,
|
|
529
|
+
* 负数/非整数由 OCR 侧报错,但桥接层先做同源校验给出更早的错误)。
|
|
530
|
+
* value 为 undefined/null 时省略 flag(跟随 OCR flag 默认值)。
|
|
531
|
+
*
|
|
532
|
+
* 注入面:数值经 `String(num)` 落进命令串(不走 shq)。Number.isInteger 对
|
|
533
|
+
* 1e21 为 true 而 String() 会写成 `1e+21`,故再加一道「只含数字」的形态校验:
|
|
534
|
+
* 命令串里的数值 token 结构上不可能带 shell 元字符。
|
|
535
|
+
*/
|
|
536
|
+
function pushIntFlag(parts, flag, value, min, messages) {
|
|
537
|
+
if (value === void 0 || value === null) return;
|
|
538
|
+
const num = typeof value === "number" ? value : Number(value);
|
|
539
|
+
const text = String(num);
|
|
540
|
+
if (!Number.isInteger(num) || num < min || !/^\d+$/u.test(text)) throw new Error(format(messages.intFlagMustBeInt, {
|
|
541
|
+
flag,
|
|
542
|
+
min: String(min),
|
|
543
|
+
received: describeValue(value)
|
|
544
|
+
}));
|
|
545
|
+
parts.push(flag, text);
|
|
546
|
+
}
|
|
547
|
+
/** 模式参数解析:workspace(默认)/commit/branch 互斥,非法组合与错类型抛错。
|
|
548
|
+
*
|
|
549
|
+
* 互斥判定必须在两个模式分支之前一次做完:若先判 commit(`commit 非空` 时条件
|
|
550
|
+
* 恒真),后面的「branch 与 commit 互斥」就永不可达,`{scope:branch, commit:x}`
|
|
551
|
+
* 会被静默降级成 commit 审查——审错范围却按结论上报。 */
|
|
552
|
+
function scopeFlags(args, messages) {
|
|
553
|
+
const scopeName = idValue(args.scope, "scope", messages).toLowerCase();
|
|
554
|
+
const scope = scopeName === "" ? "workspace" : scopeName;
|
|
555
|
+
const from = idValue(args.from, "from", messages);
|
|
556
|
+
const to = idValue(args.to, "to", messages);
|
|
557
|
+
const commit = idValue(args.commit, "commit", messages);
|
|
558
|
+
const wantsBranch = scope === "branch" || from !== "" || to !== "";
|
|
559
|
+
const wantsCommit = scope === "commit" || commit !== "";
|
|
560
|
+
if (wantsBranch && wantsCommit) throw new Error(messages.scopeBranchCommitExclusive);
|
|
561
|
+
if (wantsBranch) {
|
|
562
|
+
if (from === "" || to === "") throw new Error(messages.branchNeedsFromAndTo);
|
|
563
|
+
return [
|
|
564
|
+
"--from",
|
|
565
|
+
shq(from),
|
|
566
|
+
"--to",
|
|
567
|
+
shq(to)
|
|
568
|
+
];
|
|
569
|
+
}
|
|
570
|
+
if (wantsCommit) {
|
|
571
|
+
if (scope === "commit" && commit === "") throw new Error(messages.commitNeedsValue);
|
|
572
|
+
return ["--commit", shq(commit)];
|
|
573
|
+
}
|
|
574
|
+
if (scope !== "workspace") throw new Error(format(messages.unknownScope, { scope }));
|
|
575
|
+
return [];
|
|
576
|
+
}
|
|
577
|
+
/** 可选文本 flag:值缺省/空串则省略,错类型抛错(静默丢弃等于悄悄改变语义)。 */
|
|
578
|
+
function pushTextFlag(parts, flag, value, messages) {
|
|
579
|
+
const text = idValue(value, flag.slice(2), messages);
|
|
580
|
+
if (text !== "") parts.push(flag, shq(text));
|
|
581
|
+
}
|
|
582
|
+
/** --exclude/--background/--provider/--model 等通用 flag。
|
|
583
|
+
* 注意:ocr 的 --exclude 是 String 类型(实测 v1.12.0 多 flag 实例只认最后一个,
|
|
584
|
+
* 逗号分隔才生效)——多值必须合并为一个 flag 逗号连接,绝不能展开多个。
|
|
585
|
+
*/
|
|
586
|
+
function optionalFlags(args, messages) {
|
|
587
|
+
const parts = [];
|
|
588
|
+
const excludes = commaList(args.exclude, "exclude", 50, messages);
|
|
589
|
+
if (excludes.length > 0) parts.push("--exclude", shq(excludes.join(",")));
|
|
590
|
+
pushTextFlag(parts, BACKGROUND_FLAG, args.background, messages);
|
|
591
|
+
pushTextFlag(parts, "--provider", args.provider, messages);
|
|
592
|
+
pushTextFlag(parts, "--model", args.model, messages);
|
|
593
|
+
pushTextFlag(parts, "--resume", args.resume, messages);
|
|
594
|
+
return parts;
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* 宿主回收守护:把 review/scan 长命令包进 `bash -c`,保证 dsh 进程以任何方式
|
|
598
|
+
* 退出时 OCR 都不会变成孤儿继续跑(用户要求:dsh 彻底退出 → OCR 必须结束)。
|
|
599
|
+
*
|
|
600
|
+
* 为什么需要:macOS 上 dsh 硬退出(kill -9/崩溃)后,detached 子进程被 launchd
|
|
601
|
+
* 收养——subprocess-local 源码自述 "macOS has no supported persistent
|
|
602
|
+
* process-range owner",只能由进程自己看护自己。
|
|
603
|
+
*
|
|
604
|
+
* 两层互补:
|
|
605
|
+
* - 优雅退出(exit 命令 / Ctrl+C / SIGTERM):宿主 teardown 向组发 TERM,
|
|
606
|
+
* 守护的 `trap 'reap; exit 143' TERM INT` 收到后转发 reap。(bash 语义坑:
|
|
607
|
+
* reap 自毙后 trap 里的 exit 不会执行,所以 reap 必须自带完整链——TERM→宽限
|
|
608
|
+
* →补 KILL 全部目标,自我清场的组 KILL 排在最后一步。)
|
|
609
|
+
* - 硬退出:守护随 launchd 收养后 ppid 改变,监视循环探测 ppid != 初始值 →
|
|
610
|
+
* reap 后退出。TERM 先行是因 OCR 原生 signal.Notify
|
|
611
|
+
* (cmd/opencodereview/interrupt.go:48)会优雅落盘会话清单再退。
|
|
612
|
+
*
|
|
613
|
+
* 组语义:dsh shell runner 以 detached:true(setsid)启动 `bash -c <cmd>`,
|
|
614
|
+
* 守护即组长,`kill -- -$$` 覆盖组内任意深度后代;守护另有组长判定,非组长
|
|
615
|
+
* (手测/异常)绝不发起组杀(防误伤宿主组),改走「$ocr+递归后代快照」回退。
|
|
616
|
+
*
|
|
617
|
+
* 语义实测(本机 bash 3.2,均为源码模板逐字提取的产物):
|
|
618
|
+
* - stdio 与退出码透传(OUT/ERR/rc=7),正常退出路径绝不触发 reap;
|
|
619
|
+
* - 组长场景(detached 复刻 runner)dsh 硬退出后 +2s:stub 走 trap 优雅收尾、
|
|
620
|
+
* 孙进程随组消亡——只杀 $ocr 与其直接子级会漏孙进程(实测泄漏被收养);
|
|
621
|
+
* - 非组长场景守护收 TERM:stub/stub 包装层/孙进程全部消亡。
|
|
622
|
+
* 只包 review/scan(分钟级任务);delegate/session/llm-test 秒级命令不包。
|
|
623
|
+
*/
|
|
624
|
+
function wrapWithHostReaper(command) {
|
|
625
|
+
if (process.platform === "win32") return command;
|
|
626
|
+
return `bash -c ${shq([
|
|
627
|
+
`p0=$(ps -o ppid= -p $$ | tr -d ' ')`,
|
|
628
|
+
`{ ${command} ; } & ocr=$!`,
|
|
629
|
+
`desc() { for k in $(pgrep -P "$1" 2>/dev/null); do desc "$k"; printf '%s ' "$k"; done; }`,
|
|
630
|
+
`isl() { [ "$(ps -o pgid= -p $$ | tr -d ' ')" = "$$" ]; }`,
|
|
631
|
+
`reap() { cs=$(desc "$ocr"); isl && kill -TERM -- -$$ 2>/dev/null; kill -TERM "$ocr" 2>/dev/null; for c in $cs; do kill -TERM "$c" 2>/dev/null; done; sleep 1; killmPid(){ kill -0 "$1" 2>/dev/null && kill -KILL "$1" 2>/dev/null; }; killmPid "$ocr"; for c in $cs; do killmPid "$c"; done; isl && kill -KILL -- -$$ 2>/dev/null; }`,
|
|
632
|
+
`trap 'reap; exit 143' TERM INT`,
|
|
633
|
+
`while kill -0 "$ocr" 2>/dev/null && [ "$(ps -o ppid= -p $$ | tr -d ' ')" = "$p0" ]; do sleep 2; done`,
|
|
634
|
+
`kill -0 "$ocr" 2>/dev/null && reap`,
|
|
635
|
+
`wait "$ocr"; exit $?`
|
|
636
|
+
].join("; "))}`;
|
|
637
|
+
}
|
|
638
|
+
function locateLauncher() {
|
|
639
|
+
let launcher = null;
|
|
640
|
+
try {
|
|
641
|
+
const entry = createRequire(import.meta.url).resolve("@alibaba-group/open-code-review/package.json");
|
|
642
|
+
const candidate = path.join(path.dirname(entry), "bin", "ocr.js");
|
|
643
|
+
if (existsSync(candidate)) launcher = candidate;
|
|
644
|
+
} catch {}
|
|
645
|
+
return launcher;
|
|
646
|
+
}
|
|
647
|
+
/** 进程级记忆槽(默认那一档):整包共用一次解析结果。 */
|
|
648
|
+
const memoBox = {};
|
|
649
|
+
/** 解析出的 `ocr` 命令词(已 shq 转义):随包入口的绝对路径,或 PATH 上的裸 `ocr`。
|
|
650
|
+
* 惰性求值 + 每进程记忆一次,与 dsh 核心解析 @vscode/ripgrep 同款:解析失败绝不在
|
|
651
|
+
* 装载期抛,否则整包会因为一个可选二进制而下线。
|
|
652
|
+
* 导出给同包的命令构造单测——它们按同一个解析结果写期望值,测试与生产同源,
|
|
653
|
+
* 而不是各自把「ocr 还是某个绝对路径」写死一遍。
|
|
654
|
+
* @param locate 随包 launcher 的定位口(默认实现即生产那一档)。
|
|
655
|
+
* @param memo 记忆槽(默认进程级单例;单测传自己的盒以便反复构造两条分支)。
|
|
656
|
+
*/
|
|
657
|
+
function ocrCommand(locate = locateLauncher, memo = memoBox) {
|
|
658
|
+
if (memo.value === void 0) {
|
|
659
|
+
const launcher = locate();
|
|
660
|
+
memo.value = launcher === null ? "ocr" : shq(launcher);
|
|
661
|
+
}
|
|
662
|
+
return memo.value;
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* 构造 `ocr review` 命令。--output 固定为调用方给定的临时文件路径(防截断),
|
|
666
|
+
* --audience agent --format json 是默认(结构化、无进度行)。
|
|
667
|
+
* outputPath 缺省(后台模式)省略 --output:结果以 OCR 会话记录为准。
|
|
668
|
+
* 返回裸命令;宿主回收守护由调用方(host.ts)在执行点套 wrapWithHostReaper。
|
|
669
|
+
*/
|
|
670
|
+
function buildReviewCommand(args, outputPath, messages) {
|
|
671
|
+
const repo = assertAbsoluteRoot(args.repo, messages);
|
|
672
|
+
const parts = [
|
|
673
|
+
ocrCommand(),
|
|
674
|
+
"review",
|
|
675
|
+
"--audience",
|
|
676
|
+
"agent",
|
|
677
|
+
"--format",
|
|
678
|
+
resolveOutputFormat(args.format)
|
|
679
|
+
];
|
|
680
|
+
const effort = clampEffort(args.effort, "medium");
|
|
681
|
+
parts.push("--effort", effort);
|
|
682
|
+
pushIntFlag(parts, "--concurrency", args.concurrency, 1, messages);
|
|
683
|
+
pushIntFlag(parts, "--timeout", args.timeoutMinutes, 0, messages);
|
|
684
|
+
pushIntFlag(parts, "--max-tools", args.maxTools, 0, messages);
|
|
685
|
+
pushIntFlag(parts, "--max-tokens", args.maxTokens, 0, messages);
|
|
686
|
+
pushIntFlag(parts, "--max-tokens-budget", args.maxTokensBudget, 0, messages);
|
|
687
|
+
parts.push(...scopeFlags(args, messages), ...optionalFlags(args, messages));
|
|
688
|
+
if (outputPath !== void 0) parts.push("--output", shq(outputPath));
|
|
689
|
+
return {
|
|
690
|
+
command: parts.join(" "),
|
|
691
|
+
workdir: repo
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
/** 构造 `ocr scan` 命令(无 git diff 的全文件扫描)。
|
|
695
|
+
* 与 review 相同的坑:--path/--exclude 都是 String 类型,多值必须逗号连接
|
|
696
|
+
* (实测 v1.12.0:多 flag 实例不生效),绝不能展开多个。
|
|
697
|
+
* outputPath 缺省(后台模式)省略 --output:结果以 OCR 会话记录为准。 */
|
|
698
|
+
function buildScanCommand(args, outputPath, messages) {
|
|
699
|
+
const repo = assertAbsoluteRoot(args.repo, messages);
|
|
700
|
+
const parts = [
|
|
701
|
+
ocrCommand(),
|
|
702
|
+
"scan",
|
|
703
|
+
"--audience",
|
|
704
|
+
"agent",
|
|
705
|
+
"--format",
|
|
706
|
+
resolveOutputFormat(args.format)
|
|
707
|
+
];
|
|
708
|
+
const paths = commaList(args.path, "path", 100, messages);
|
|
709
|
+
if (paths.length > 0) parts.push("--path", shq(paths.join(",")));
|
|
710
|
+
const excludes = commaList(args.exclude, "exclude", 50, messages);
|
|
711
|
+
if (excludes.length > 0) parts.push("--exclude", shq(excludes.join(",")));
|
|
712
|
+
pushTextFlag(parts, "--batch", args.batch, messages);
|
|
713
|
+
pushIntFlag(parts, "--concurrency", args.concurrency, 1, messages);
|
|
714
|
+
pushIntFlag(parts, "--timeout", args.timeoutMinutes, 0, messages);
|
|
715
|
+
pushIntFlag(parts, "--max-tools", args.maxTools, 0, messages);
|
|
716
|
+
pushIntFlag(parts, "--max-tokens", args.maxTokens, 0, messages);
|
|
717
|
+
pushIntFlag(parts, "--max-tokens-budget", args.maxTokensBudget, 0, messages);
|
|
718
|
+
pushTextFlag(parts, BACKGROUND_FLAG, args.background, messages);
|
|
719
|
+
if (outputPath !== void 0) parts.push("--output", shq(outputPath));
|
|
720
|
+
return {
|
|
721
|
+
command: parts.join(" "),
|
|
722
|
+
workdir: repo
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
/** 构造 `ocr delegate preview` 命令(只列文件清单,不跑 LLM)。 */
|
|
726
|
+
function buildDelegatePreviewCommand(args, messages) {
|
|
727
|
+
const repo = assertAbsoluteRoot(args.repo, messages);
|
|
728
|
+
const scopeArgs = {
|
|
729
|
+
...args,
|
|
730
|
+
format: "json"
|
|
731
|
+
};
|
|
732
|
+
const parts = [
|
|
733
|
+
ocrCommand(),
|
|
734
|
+
"delegate",
|
|
735
|
+
"preview",
|
|
736
|
+
"--format",
|
|
737
|
+
"json",
|
|
738
|
+
...scopeFlags(scopeArgs, messages)
|
|
739
|
+
];
|
|
740
|
+
const excludes = commaList(args.exclude, "exclude", 50, messages);
|
|
741
|
+
if (excludes.length > 0) parts.push("--exclude", shq(excludes.join(",")));
|
|
742
|
+
pushTextFlag(parts, BACKGROUND_FLAG, args.background, messages);
|
|
743
|
+
return {
|
|
744
|
+
command: parts.join(" "),
|
|
745
|
+
workdir: repo
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
/** 构造 `ocr delegate rule` 命令(路径为位置参数,逐条 shq 转义)。 */
|
|
749
|
+
function buildDelegateRuleCommand(args, messages) {
|
|
750
|
+
const repo = assertAbsoluteRoot(args.repo, messages);
|
|
751
|
+
const paths = commaList(args.paths, "paths", 200, messages).map((filePath) => shq(filePath));
|
|
752
|
+
if (paths.length === 0) throw new Error(messages.pathsRequired);
|
|
753
|
+
return {
|
|
754
|
+
command: [
|
|
755
|
+
ocrCommand(),
|
|
756
|
+
"delegate",
|
|
757
|
+
"rule",
|
|
758
|
+
"--format",
|
|
759
|
+
"json",
|
|
760
|
+
"--",
|
|
761
|
+
...paths
|
|
762
|
+
].join(" "),
|
|
763
|
+
workdir: repo
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
/** session 子命令白名单(子命令名裸拼进命令串,只可能是这三个字面量)。 */
|
|
767
|
+
const SESSION_ACTIONS = [
|
|
768
|
+
"list",
|
|
769
|
+
"show",
|
|
770
|
+
"comments"
|
|
771
|
+
];
|
|
772
|
+
/** 构造 `ocr session list/show/comments` 命令(历史评审会话闭环,Json 输出)。 */
|
|
773
|
+
function buildSessionCommand(args, messages) {
|
|
774
|
+
const repo = assertAbsoluteRoot(args.repo, messages);
|
|
775
|
+
const id = idValue(args.id, "id", messages);
|
|
776
|
+
const action = idValue(args.action, "action", messages);
|
|
777
|
+
const requested = SESSION_ACTIONS.find((name) => name === action);
|
|
778
|
+
if (action !== "" && requested === void 0) throw new Error(format(messages.invalidAction, {
|
|
779
|
+
action,
|
|
780
|
+
expected: SESSION_ACTIONS.join("/")
|
|
781
|
+
}));
|
|
782
|
+
let sub;
|
|
783
|
+
if (requested === "comments" || requested === "show") sub = id === "" ? "list" : requested;
|
|
784
|
+
else sub = id === "" ? "list" : "show";
|
|
785
|
+
const parts = [
|
|
786
|
+
ocrCommand(),
|
|
787
|
+
"session",
|
|
788
|
+
sub
|
|
789
|
+
];
|
|
790
|
+
if (sub === "list") {
|
|
791
|
+
parts.push("--json", "--repo", shq(repo));
|
|
792
|
+
const limit = clampPositiveInt(args.limit, 10, 1, 100);
|
|
793
|
+
parts.push("--limit", String(limit));
|
|
794
|
+
} else parts.push("--json", "--repo", shq(repo), shq(id));
|
|
795
|
+
return {
|
|
796
|
+
command: parts.join(" "),
|
|
797
|
+
workdir: repo
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
//#endregion
|
|
801
|
+
//#region lib/severity.ts
|
|
802
|
+
const SEVERITY_ORDER = [
|
|
803
|
+
"critical",
|
|
804
|
+
"high",
|
|
805
|
+
"medium",
|
|
806
|
+
"low"
|
|
807
|
+
];
|
|
808
|
+
/**
|
|
809
|
+
* 严重度归一:白名单外的值(含 OCR 自造等级、大小写异形)一律按 medium。
|
|
810
|
+
* 聚合与排序共用这一个函数——此前两处各自实现(排序 indexOf 未命中得 -1 而把
|
|
811
|
+
* UNKNOWN 排到 critical 之前,聚合却归 medium),同一份数据两种口径。
|
|
812
|
+
*/
|
|
813
|
+
function normalizeSeverity(severity) {
|
|
814
|
+
return SEVERITY_ORDER.find((level) => level === severity) ?? "medium";
|
|
815
|
+
}
|
|
816
|
+
/** 排序位:critical→high→medium→low(未知已归 medium,永不为 -1)。 */
|
|
817
|
+
function severityRank(severity) {
|
|
818
|
+
return SEVERITY_ORDER.indexOf(normalizeSeverity(severity));
|
|
819
|
+
}
|
|
820
|
+
/** 聚合评论:按严重度/类别计数。severity 归类以 critical→high→medium→low 基准。 */
|
|
821
|
+
function aggregateComments(comments) {
|
|
822
|
+
const bySeverity = {
|
|
823
|
+
critical: 0,
|
|
824
|
+
high: 0,
|
|
825
|
+
medium: 0,
|
|
826
|
+
low: 0
|
|
827
|
+
};
|
|
828
|
+
const byCategory = {};
|
|
829
|
+
for (const comment of comments) {
|
|
830
|
+
const sev = normalizeSeverity(comment.severity);
|
|
831
|
+
bySeverity[sev] += 1;
|
|
832
|
+
if (comment.category) byCategory[comment.category] = (byCategory[comment.category] ?? 0) + 1;
|
|
833
|
+
}
|
|
834
|
+
return {
|
|
835
|
+
total: comments.length,
|
|
836
|
+
bySeverity,
|
|
837
|
+
byCategory
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
//#endregion
|
|
841
|
+
//#region lib/parse.ts
|
|
842
|
+
/**
|
|
843
|
+
* OCR 评审 JSON 的 status 枚举(docs/cli-reference + 1.12.0 实测)。
|
|
844
|
+
* 白名单外的一律视为失败:exit 0 不等于评审成功——`{"error":"auth failed"}`
|
|
845
|
+
* 这类「没有 status 的错误对象」若被当作 ok:true,模型会把「0 条评论」读成
|
|
846
|
+
* 「代码干净」,是最坏的一类假阳性。
|
|
847
|
+
*/
|
|
848
|
+
const REVIEW_STATUSES = [
|
|
849
|
+
"success",
|
|
850
|
+
"complete",
|
|
851
|
+
"completed_with_warnings",
|
|
852
|
+
"completed_with_errors",
|
|
853
|
+
"skipped"
|
|
854
|
+
];
|
|
855
|
+
/** 未知 JSON 值 → 安全字符串(非字符串/缺省给 '')。 */
|
|
856
|
+
function str(value) {
|
|
857
|
+
if (typeof value === "string") return value;
|
|
858
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
859
|
+
return "";
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* 数值字段解析:number 与「数字字符串」都接受(OCR 部分 provider 实测把
|
|
863
|
+
* start_line 输出成 "42",一律当 0 会把真实评论判成「未锚定」而被摘要丢掉),
|
|
864
|
+
* 空串/NaN/Infinity/其它类型回 0。
|
|
865
|
+
*/
|
|
866
|
+
function num(value) {
|
|
867
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : 0;
|
|
868
|
+
if (typeof value === "string") {
|
|
869
|
+
const trimmed = value.trim();
|
|
870
|
+
if (trimmed === "") return 0;
|
|
871
|
+
const parsed = Number(trimmed);
|
|
872
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
873
|
+
}
|
|
874
|
+
return 0;
|
|
875
|
+
}
|
|
876
|
+
function commentOf(raw) {
|
|
877
|
+
if (!isRecord(raw)) return null;
|
|
878
|
+
const content = str(raw["content"]);
|
|
879
|
+
if (content.length === 0) return null;
|
|
880
|
+
return {
|
|
881
|
+
path: str(raw["path"]),
|
|
882
|
+
content,
|
|
883
|
+
startLine: num(raw["start_line"]),
|
|
884
|
+
endLine: num(raw["end_line"]),
|
|
885
|
+
category: str(raw["category"]),
|
|
886
|
+
severity: (str(raw["severity"]) || "medium").toLowerCase(),
|
|
887
|
+
existingCode: str(raw["existing_code"]),
|
|
888
|
+
suggestionCode: str(raw["suggestion_code"])
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
/** 失败摘要(status 非法/JSON 不合形态时的统一返回,避免多处重复骨架)。 */
|
|
892
|
+
function reviewFailure(status, message) {
|
|
893
|
+
return {
|
|
894
|
+
ok: false,
|
|
895
|
+
status,
|
|
896
|
+
message,
|
|
897
|
+
skipped: false,
|
|
898
|
+
summary: null,
|
|
899
|
+
comments: [],
|
|
900
|
+
warnings: [],
|
|
901
|
+
sessionId: "",
|
|
902
|
+
droppedComments: 0
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* 解析 review/scan JSON。text 应为 `ocr review --format json` 的完整 stdout/
|
|
907
|
+
* 输出文件内容。解析失败(非 JSON)或 status 不在白名单(含缺失,例如
|
|
908
|
+
* `{"error":"..."}` 这类 exit 0 的错误对象)返回 ok=false,由调用方报错。
|
|
909
|
+
*/
|
|
910
|
+
function parseReviewOutput(text, messages) {
|
|
911
|
+
let doc;
|
|
912
|
+
try {
|
|
913
|
+
doc = JSON.parse(text);
|
|
914
|
+
} catch {
|
|
915
|
+
return reviewFailure("unparseable", messages.ocrNotJson);
|
|
916
|
+
}
|
|
917
|
+
if (!isRecord(doc)) return reviewFailure("unparseable", messages.ocrNotObject);
|
|
918
|
+
const root = doc;
|
|
919
|
+
const rawStatus = root["status"];
|
|
920
|
+
const status = REVIEW_STATUSES.find((level) => level === rawStatus);
|
|
921
|
+
if (status === void 0) return reviewFailure("invalid", format(messages.ocrStatusInvalid, {
|
|
922
|
+
status: str(rawStatus) || messages.statusNone,
|
|
923
|
+
expected: REVIEW_STATUSES.join("/")
|
|
924
|
+
}));
|
|
925
|
+
const commentsRaw = root["comments"];
|
|
926
|
+
if (commentsRaw !== void 0 && !Array.isArray(commentsRaw)) return reviewFailure("invalid", messages.ocrCommentsNotArray);
|
|
927
|
+
const comments = [];
|
|
928
|
+
let droppedComments = 0;
|
|
929
|
+
const rawComments = commentsRaw ?? [];
|
|
930
|
+
for (const raw of rawComments) {
|
|
931
|
+
const comment = commentOf(raw);
|
|
932
|
+
if (comment === null) droppedComments += 1;
|
|
933
|
+
else comments.push(comment);
|
|
934
|
+
}
|
|
935
|
+
const skipped = status === "skipped";
|
|
936
|
+
const sumRaw = root["summary"];
|
|
937
|
+
const sum = isRecord(sumRaw) ? sumRaw : void 0;
|
|
938
|
+
const warningsArr = Array.isArray(root["warnings"]) ? root["warnings"].map((warn) => {
|
|
939
|
+
if (typeof warn === "string") return warn;
|
|
940
|
+
if (isRecord(warn)) return [str(warn["file"]), str(warn["error"])].filter(Boolean).join(messages.warningSeparator);
|
|
941
|
+
return "";
|
|
942
|
+
}).filter(Boolean) : [];
|
|
943
|
+
return {
|
|
944
|
+
ok: true,
|
|
945
|
+
status,
|
|
946
|
+
message: str(root["message"]),
|
|
947
|
+
skipped,
|
|
948
|
+
summary: sum ? {
|
|
949
|
+
filesReviewed: num(sum["files_reviewed"]),
|
|
950
|
+
comments: num(sum["comments"]),
|
|
951
|
+
totalTokens: num(sum["total_tokens"]),
|
|
952
|
+
inputTokens: num(sum["input_tokens"]),
|
|
953
|
+
outputTokens: num(sum["output_tokens"]),
|
|
954
|
+
elapsed: str(sum["elapsed"])
|
|
955
|
+
} : null,
|
|
956
|
+
comments,
|
|
957
|
+
warnings: warningsArr,
|
|
958
|
+
sessionId: str(root["session_id"]),
|
|
959
|
+
droppedComments
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
/** 默认摘要计划(no-object-as-default-parameter:避免对象字面量作参数默认值)。 */
|
|
963
|
+
const DEFAULT_PLAN = {
|
|
964
|
+
maxComments: 12,
|
|
965
|
+
includeZeroLine: false
|
|
966
|
+
};
|
|
967
|
+
/** 单条评论的 top 位置(0 行未锚定降级为 :0)。 */
|
|
968
|
+
function locationOf(comment) {
|
|
969
|
+
const fallbackPath = comment.path || "(path unknown)";
|
|
970
|
+
if (comment.startLine <= 0) return `${fallbackPath}:0`;
|
|
971
|
+
const suffix = comment.endLine > comment.startLine ? `-${comment.endLine}` : "";
|
|
972
|
+
return `${fallbackPath}:${comment.startLine}${suffix}`;
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* 生成工具返回给模型的结构化摘要(省 token:给聚合与 top-N 评论,不塞全量)。
|
|
976
|
+
* 输出为可直接 render 的普通对象,避免 JSON 里塞超大字符串。
|
|
977
|
+
*/
|
|
978
|
+
function summarizeReview(parsed, messages, plan = DEFAULT_PLAN) {
|
|
979
|
+
const agg = aggregateComments(parsed.comments);
|
|
980
|
+
const unlimited = plan.maxComments <= 0;
|
|
981
|
+
const keepZeroLine = plan.includeZeroLine === true;
|
|
982
|
+
const ranked = [...parsed.comments].toSorted((x, y) => severityRank(x.severity) - severityRank(y.severity) || x.startLine - y.startLine).filter((comment) => unlimited || keepZeroLine || comment.startLine > 0).slice(0, unlimited ? void 0 : plan.maxComments);
|
|
983
|
+
return {
|
|
984
|
+
status: parsed.status,
|
|
985
|
+
skipped: parsed.skipped,
|
|
986
|
+
message: parsed.message || null,
|
|
987
|
+
summary: parsed.summary ?? null,
|
|
988
|
+
sessionId: parsed.sessionId || null,
|
|
989
|
+
aggregation: agg,
|
|
990
|
+
totalCommentCount: parsed.comments.length,
|
|
991
|
+
droppedCount: parsed.comments.length - ranked.length,
|
|
992
|
+
invalidCommentCount: parsed.droppedComments,
|
|
993
|
+
topComments: ranked.map((comment) => ({
|
|
994
|
+
location: locationOf(comment),
|
|
995
|
+
severity: comment.severity,
|
|
996
|
+
category: comment.category,
|
|
997
|
+
content: comment.content,
|
|
998
|
+
suggestion: comment.suggestionCode || null
|
|
999
|
+
})),
|
|
1000
|
+
warnings: parsed.warnings,
|
|
1001
|
+
hint: messages.reviewSummaryHint
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
/** preview/rule 文档各自的契约键:一个都不命中即视为「不是该命令的输出」。 */
|
|
1005
|
+
const DELEGATE_PREVIEW_KEYS = [
|
|
1006
|
+
"mode",
|
|
1007
|
+
"repository",
|
|
1008
|
+
"total_files",
|
|
1009
|
+
"reviewable_files"
|
|
1010
|
+
];
|
|
1011
|
+
const DELEGATE_RULE_KEYS = ["groups", "schema_version"];
|
|
1012
|
+
function hitsContract(root, keys) {
|
|
1013
|
+
return keys.some((key) => root[key] !== void 0);
|
|
1014
|
+
}
|
|
1015
|
+
/** preview 失败骨架(解析失败/非对象/不合契约三处共用)。 */
|
|
1016
|
+
function delegatePreviewFailure() {
|
|
1017
|
+
return {
|
|
1018
|
+
ok: false,
|
|
1019
|
+
mode: "",
|
|
1020
|
+
repository: "",
|
|
1021
|
+
totalFiles: 0,
|
|
1022
|
+
reviewable: [],
|
|
1023
|
+
excluded: [],
|
|
1024
|
+
mergeBase: ""
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
/** 解析 `ocr delegate preview --format json` 输出。 */
|
|
1028
|
+
function parseDelegatePreview(text) {
|
|
1029
|
+
let doc;
|
|
1030
|
+
try {
|
|
1031
|
+
doc = JSON.parse(text);
|
|
1032
|
+
} catch {
|
|
1033
|
+
return delegatePreviewFailure();
|
|
1034
|
+
}
|
|
1035
|
+
if (!isRecord(doc)) return delegatePreviewFailure();
|
|
1036
|
+
const root = doc;
|
|
1037
|
+
if (!hitsContract(root, DELEGATE_PREVIEW_KEYS)) return delegatePreviewFailure();
|
|
1038
|
+
const reviewable = Array.isArray(root["reviewable_files"]) ? root["reviewable_files"].map((fileRaw) => {
|
|
1039
|
+
if (!isRecord(fileRaw)) return null;
|
|
1040
|
+
return {
|
|
1041
|
+
path: str(fileRaw["path"]),
|
|
1042
|
+
status: str(fileRaw["status"]),
|
|
1043
|
+
insertions: num(fileRaw["insertions"]),
|
|
1044
|
+
deletions: num(fileRaw["deletions"])
|
|
1045
|
+
};
|
|
1046
|
+
}).filter((fileEntry) => fileEntry !== null && fileEntry.path.length > 0) : [];
|
|
1047
|
+
const excluded = Array.isArray(root["excluded_files"]) ? root["excluded_files"].map((fileRaw) => {
|
|
1048
|
+
if (!isRecord(fileRaw)) return null;
|
|
1049
|
+
return {
|
|
1050
|
+
path: str(fileRaw["path"]),
|
|
1051
|
+
reason: str(fileRaw["reason"])
|
|
1052
|
+
};
|
|
1053
|
+
}).filter((fileEntry) => fileEntry !== null && fileEntry.path.length > 0) : [];
|
|
1054
|
+
return {
|
|
1055
|
+
ok: true,
|
|
1056
|
+
mode: str(root["mode"]),
|
|
1057
|
+
repository: str(root["repository"]),
|
|
1058
|
+
totalFiles: num(root["total_files"]) || num(root["reviewable_count"]),
|
|
1059
|
+
reviewable,
|
|
1060
|
+
excluded,
|
|
1061
|
+
mergeBase: str(root["merge_base"])
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
/** 解析 `ocr delegate rule --format json` 输出。 */
|
|
1065
|
+
function parseDelegateRules(text) {
|
|
1066
|
+
let doc;
|
|
1067
|
+
try {
|
|
1068
|
+
doc = JSON.parse(text);
|
|
1069
|
+
} catch {
|
|
1070
|
+
return {
|
|
1071
|
+
ok: false,
|
|
1072
|
+
groups: []
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
if (!isRecord(doc)) return {
|
|
1076
|
+
ok: false,
|
|
1077
|
+
groups: []
|
|
1078
|
+
};
|
|
1079
|
+
const root = doc;
|
|
1080
|
+
if (!hitsContract(root, DELEGATE_RULE_KEYS)) return {
|
|
1081
|
+
ok: false,
|
|
1082
|
+
groups: []
|
|
1083
|
+
};
|
|
1084
|
+
return {
|
|
1085
|
+
ok: true,
|
|
1086
|
+
groups: Array.isArray(root["groups"]) ? root["groups"].map((groupRaw) => {
|
|
1087
|
+
if (!isRecord(groupRaw)) return null;
|
|
1088
|
+
return {
|
|
1089
|
+
groupId: num(groupRaw["group_id"]),
|
|
1090
|
+
source: str(groupRaw["source"]),
|
|
1091
|
+
pattern: str(groupRaw["pattern"]),
|
|
1092
|
+
files: Array.isArray(groupRaw["files"]) ? groupRaw["files"].map(str).filter(Boolean) : [],
|
|
1093
|
+
rule: str(groupRaw["rule"])
|
|
1094
|
+
};
|
|
1095
|
+
}).filter((group) => group !== null && group.files.length > 0) : []
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
//#endregion
|
|
1099
|
+
//#region lib/routing.ts
|
|
1100
|
+
const ROUTING_NAME = "ocr-review-routing";
|
|
1101
|
+
const ROUTING_ORDER = 1555;
|
|
1102
|
+
//#endregion
|
|
1103
|
+
//#region lib/provider-projection.ts
|
|
1104
|
+
/** llm-pi-ai 命名空间里 provider 表的字段名。 */
|
|
1105
|
+
const PROVIDERS_FIELD = "providers";
|
|
1106
|
+
/** 原始 model 对象 → DshModel(缺 id 视为无效,返回 null)。 */
|
|
1107
|
+
function modelFromRaw(modelRaw) {
|
|
1108
|
+
if (!isRecord(modelRaw)) return null;
|
|
1109
|
+
const rec = modelRaw;
|
|
1110
|
+
const id = typeof rec["id"] === "string" ? rec["id"] : "";
|
|
1111
|
+
if (!id) return null;
|
|
1112
|
+
return {
|
|
1113
|
+
id,
|
|
1114
|
+
name: typeof rec["name"] === "string" && rec["name"] ? rec["name"] : id
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
/** 原始 provider 对象 → DshProvider(缺 baseURL 视为无效,返回 null)。 */
|
|
1118
|
+
function providerFromRaw(name, raw) {
|
|
1119
|
+
if (!isRecord(raw)) return null;
|
|
1120
|
+
const entry = raw;
|
|
1121
|
+
const apiKeyEnv = typeof entry["apiKeyEnv"] === "string" && entry["apiKeyEnv"].trim() ? entry["apiKeyEnv"].trim() : null;
|
|
1122
|
+
const protocol = entry["api"] === "openai-responses" ? "openai-responses" : "openai";
|
|
1123
|
+
const baseURL = typeof entry["baseURL"] === "string" ? entry["baseURL"] : "";
|
|
1124
|
+
if (!baseURL) return null;
|
|
1125
|
+
const models = Array.isArray(entry["models"]) ? entry["models"].map(modelFromRaw).filter((model) => model !== null) : [];
|
|
1126
|
+
return {
|
|
1127
|
+
name,
|
|
1128
|
+
displayName: typeof entry["displayName"] === "string" && entry["displayName"] ? entry["displayName"] : name,
|
|
1129
|
+
apiKeyEnv,
|
|
1130
|
+
protocol,
|
|
1131
|
+
baseURL,
|
|
1132
|
+
models
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* settings 命名空间解析值 → provider 列表(纯投影,不含任何 key 值)。
|
|
1137
|
+
* 上游形状(settings 里 llm-pi-ai.providers.<name>):
|
|
1138
|
+
* { displayName?, apiKeyEnv?, api: openai-completions|openai-responses,
|
|
1139
|
+
* baseURL, models?: [{id,name,…}] }
|
|
1140
|
+
* 形状不合的条目整条丢弃(读侧宽容:卡片要能打开让用户去修),写侧才 fail-loud。
|
|
1141
|
+
*/
|
|
1142
|
+
function providersFromSettingsValue(value) {
|
|
1143
|
+
const providers = fieldOf(value, PROVIDERS_FIELD);
|
|
1144
|
+
if (!isRecord(providers)) return [];
|
|
1145
|
+
const out = [];
|
|
1146
|
+
for (const [name, raw] of Object.entries(providers)) {
|
|
1147
|
+
const parsed = providerFromRaw(name, raw);
|
|
1148
|
+
if (parsed !== null) out.push(parsed);
|
|
1149
|
+
}
|
|
1150
|
+
return out;
|
|
1151
|
+
}
|
|
1152
|
+
//#endregion
|
|
1153
|
+
//#region lib/provider-source.ts
|
|
1154
|
+
/** provider 清单所在的官方 settings 命名空间(base bundle 的 pi-ai 适配器注册)。 */
|
|
1155
|
+
const PI_AI_NAMESPACE = "llm-pi-ai";
|
|
1156
|
+
const SOURCE_READY = {
|
|
1157
|
+
status: "ready",
|
|
1158
|
+
message: ""
|
|
1159
|
+
};
|
|
1160
|
+
/**
|
|
1161
|
+
* 读 provider 清单并把一切失败折进 source(不抛):会话热路径与 webServer 端点
|
|
1162
|
+
* 都只消费返回值,服务缺席/抛错时卡片仍能显示原因。
|
|
1163
|
+
*/
|
|
1164
|
+
function readProviders(gateway, messages) {
|
|
1165
|
+
const health = gateway.health();
|
|
1166
|
+
if (!health.settings) return {
|
|
1167
|
+
providers: [],
|
|
1168
|
+
source: {
|
|
1169
|
+
status: "settings-unavailable",
|
|
1170
|
+
message: messages.sourceSettingsUnavailable
|
|
1171
|
+
}
|
|
1172
|
+
};
|
|
1173
|
+
let descriptors;
|
|
1174
|
+
try {
|
|
1175
|
+
descriptors = gateway.descriptors();
|
|
1176
|
+
} catch (error) {
|
|
1177
|
+
return {
|
|
1178
|
+
providers: [],
|
|
1179
|
+
source: {
|
|
1180
|
+
status: "settings-failed",
|
|
1181
|
+
message: format(messages.sourceSettingsFailed, { message: errorText(error) })
|
|
1182
|
+
}
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
const hit = descriptors.find((descriptor) => descriptor.ns === PI_AI_NAMESPACE);
|
|
1186
|
+
if (hit === void 0) return {
|
|
1187
|
+
providers: [],
|
|
1188
|
+
source: {
|
|
1189
|
+
status: "namespace-unavailable",
|
|
1190
|
+
message: format(messages.sourceNamespaceUnavailable, { ns: PI_AI_NAMESPACE })
|
|
1191
|
+
}
|
|
1192
|
+
};
|
|
1193
|
+
const providers = providersFromSettingsValue(hit.value);
|
|
1194
|
+
if (!health.credentials) return {
|
|
1195
|
+
providers,
|
|
1196
|
+
source: {
|
|
1197
|
+
status: "credentials-unavailable",
|
|
1198
|
+
message: messages.sourceCredentialsUnavailable
|
|
1199
|
+
}
|
|
1200
|
+
};
|
|
1201
|
+
return {
|
|
1202
|
+
providers,
|
|
1203
|
+
source: SOURCE_READY
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
/** 逐 provider 问凭据服务「有没有 key」(并行;只回布尔)。 */
|
|
1207
|
+
async function markKeyFlags(gateway, providers) {
|
|
1208
|
+
return await Promise.all(providers.map(async (providerCfg) => {
|
|
1209
|
+
const hasKey = providerCfg.apiKeyEnv === null ? false : await gateway.credentialConfigured(providerCfg.apiKeyEnv);
|
|
1210
|
+
return {
|
|
1211
|
+
...providerCfg,
|
|
1212
|
+
hasKey
|
|
1213
|
+
};
|
|
1214
|
+
}));
|
|
1215
|
+
}
|
|
1216
|
+
//#endregion
|
|
1217
|
+
//#region lib/config-store.ts
|
|
1218
|
+
/** api_key_cmd 脚本名(pluginDir 兜底路径的唯一拼法来源)。 */
|
|
1219
|
+
const GET_CRED_SCRIPT = path.join("scripts", "get-cred.mjs");
|
|
1220
|
+
/** 未知值 → 安全字符串(非字符串给 '')。 */
|
|
1221
|
+
function strOf(value) {
|
|
1222
|
+
return typeof value === "string" ? value : "";
|
|
1223
|
+
}
|
|
1224
|
+
/** 读取外部 ocr CLI config.json 的当前形态(不含 key 值)。
|
|
1225
|
+
* 只读侧刻意保持宽容:坏 JSON 也要能让设置卡片打开(用户正是靠卡片去修),
|
|
1226
|
+
* 写侧由 parseExistingConfig fail-loud——两者语义不同,不可照抄。 */
|
|
1227
|
+
function loadOcrConfig(configJsonText) {
|
|
1228
|
+
let doc;
|
|
1229
|
+
try {
|
|
1230
|
+
const parsed = JSON.parse(configJsonText);
|
|
1231
|
+
doc = isRecord(parsed) ? parsed : {};
|
|
1232
|
+
} catch {
|
|
1233
|
+
doc = {};
|
|
1234
|
+
}
|
|
1235
|
+
const provider = strOf(fieldOf(doc, "provider"));
|
|
1236
|
+
const custom = fieldOf(doc, "custom_providers");
|
|
1237
|
+
const entry = isRecord(custom) ? fieldOf(custom, provider) : void 0;
|
|
1238
|
+
return {
|
|
1239
|
+
current: {
|
|
1240
|
+
provider,
|
|
1241
|
+
model: isRecord(custom) ? strOf(fieldOf(entry, "model")) : "",
|
|
1242
|
+
keyIsDynamic: isRecord(custom) && strOf(fieldOf(entry, "api_key_cmd")).length > 0,
|
|
1243
|
+
url: isRecord(custom) ? strOf(fieldOf(entry, "url")) : ""
|
|
1244
|
+
},
|
|
1245
|
+
raw: doc
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
/** config.json 的当前文本(读不到/不存在一律空串,交由 loadOcrConfig 的宽容读侧处理)。 */
|
|
1249
|
+
function readText(configPath) {
|
|
1250
|
+
try {
|
|
1251
|
+
return readFileSync(configPath, "utf8");
|
|
1252
|
+
} catch {
|
|
1253
|
+
return "";
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
/** 单引号转义(api_key_cmd 内嵌命令用,模块级纯函数)。 */
|
|
1257
|
+
function shqDyn(value) {
|
|
1258
|
+
return `'${value.replaceAll("'", String.raw`'\''`)}'`;
|
|
1259
|
+
}
|
|
1260
|
+
/** api_key_cmd 值构造:把脚本路径与 env key 名 shq 转义后拼成 shell 命令。 */
|
|
1261
|
+
function buildApiKeyCmd(scriptPath, envKey, messages) {
|
|
1262
|
+
if (!/^[A-Z0-9_]+$/u.test(envKey)) throw new Error(format(messages.badEnvKey, { key: envKey }));
|
|
1263
|
+
return `node ${shqDyn(scriptPath)} ${shqDyn(envKey)}`;
|
|
1264
|
+
}
|
|
1265
|
+
/** 现有 config 文本 → 盘面对象。
|
|
1266
|
+
*
|
|
1267
|
+
* 解析失败必须抛错而非给 `{}`:调用方(renderSelectedConfig /
|
|
1268
|
+
* migratePlaintextKeys)拿返回值做「保留未知键」的基准,静默变成空对象就等于
|
|
1269
|
+
* 把用户全部 providers/keys/mcp_servers 配置当成「不存在」写掉——一个手滑写坏
|
|
1270
|
+
* 的 config.json(合法场景:OCR 崩溃时留下半截文件)从此再也回不来。 */
|
|
1271
|
+
function parseExistingConfig(text, where, messages) {
|
|
1272
|
+
if (!text.trim()) return {};
|
|
1273
|
+
let parsed;
|
|
1274
|
+
try {
|
|
1275
|
+
parsed = JSON.parse(text);
|
|
1276
|
+
} catch (error) {
|
|
1277
|
+
throw new Error(format(messages.configNotJson, {
|
|
1278
|
+
where,
|
|
1279
|
+
cause: String(error)
|
|
1280
|
+
}), { cause: error });
|
|
1281
|
+
}
|
|
1282
|
+
if (!isRecord(parsed)) throw new Error(format(messages.configNotObject, { where }));
|
|
1283
|
+
return parsed;
|
|
1284
|
+
}
|
|
1285
|
+
/**
|
|
1286
|
+
* 把「读 config.json → 改 → 原子写回」整段串行化。
|
|
1287
|
+
*
|
|
1288
|
+
* 为什么需要:落盘交给官方异步 `writeFileAtomic` 之后,原本同处一个
|
|
1289
|
+
* event-loop tick 内的读写对被拆开了——卡片的「应用 provider」与「一键迁移」并发
|
|
1290
|
+
* 时可以各自用旧快照覆盖对方的整份文档,最坏情况是刚迁走的明文 api_key 又被写回来。
|
|
1291
|
+
* 锁用官方 `dsh-atomic-write` 的 `withFileLock`(`<目标>.lock`、`wx` 独占创建、
|
|
1292
|
+
* 持锁进程已退出按 pid 探活接管、`finally` 删除),本仓不自制互斥件。
|
|
1293
|
+
* 先补一次父目录:官方件只在写目标文件时建目录,而锁文件本身就落在那个目录里,
|
|
1294
|
+
* 首写(目录尚不存在)时不先建就会 ENOENT。
|
|
1295
|
+
*/
|
|
1296
|
+
async function updateConfigLocked(targetPath, operation) {
|
|
1297
|
+
await mkdir(path.dirname(targetPath), {
|
|
1298
|
+
recursive: true,
|
|
1299
|
+
mode: 448
|
|
1300
|
+
});
|
|
1301
|
+
return withFileLock(targetPath, operation);
|
|
1302
|
+
}
|
|
1303
|
+
/**
|
|
1304
|
+
* 原子写回 config.json:先留 `.bak` 备份,再交官方 `writeFileAtomic` 落盘。
|
|
1305
|
+
* 此前是 `writeFileSync` 直写:进程/磁盘在写入中途出问题就会留下半截配置
|
|
1306
|
+
* (OCR 与用户都读不到),且没有任何回滚点。rename 在同目录内是原子的。
|
|
1307
|
+
*
|
|
1308
|
+
* 随机后缀临时文件 + `wx` 独占创建、失败清临时文件、Windows rename 重试、父目录
|
|
1309
|
+
* 创建(`dirMode`)与新 inode 携带 `mode` 全是官方件既有行为,本仓不再手抄
|
|
1310
|
+
* (实测)。本地只剩 `.bak`:官方写文件面无备份语义,且备份必须落在
|
|
1311
|
+
* rename **之前**——挪到之后备份到的就是新内容。
|
|
1312
|
+
*/
|
|
1313
|
+
async function writeConfigAtomic(targetPath, text) {
|
|
1314
|
+
const bakPath = `${targetPath}.bak`;
|
|
1315
|
+
if (existsSync(targetPath)) {
|
|
1316
|
+
copyFileSync(targetPath, bakPath);
|
|
1317
|
+
chmodSync(bakPath, 384);
|
|
1318
|
+
}
|
|
1319
|
+
await writeFileAtomic(targetPath, text, {
|
|
1320
|
+
mode: 384,
|
|
1321
|
+
dirMode: 448
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
/**
|
|
1325
|
+
* 生成「选择 provider+model」后的 config.json 全文。
|
|
1326
|
+
* - provider 顶层设为目标名;custom_providers.<name> 写 url/protocol/model/
|
|
1327
|
+
* api_key_cmd;同条目残留明文 api_key 清除。
|
|
1328
|
+
* - 目标条目上已有的其它键(extra_headers/temperature/timeout_seconds…)与
|
|
1329
|
+
* 其余顶层键(language/max_tokens/effort/mcp_servers…)、其它
|
|
1330
|
+
* custom_providers 条目一律原样保留,绝不丢配置。
|
|
1331
|
+
*/
|
|
1332
|
+
function renderSelectedConfig(input, messages) {
|
|
1333
|
+
const doc = parseExistingConfig(input.existingText, input.configLabel, messages);
|
|
1334
|
+
const target = input.providers.find((providerCfg) => providerCfg.name === input.provider);
|
|
1335
|
+
if (!target) throw new Error(format(messages.providerNotInList, {
|
|
1336
|
+
ns: PI_AI_NAMESPACE,
|
|
1337
|
+
provider: input.provider
|
|
1338
|
+
}));
|
|
1339
|
+
if (!(target.models.length === 0 || target.models.some((model) => model.id === input.model))) throw new Error(format(messages.modelNotInList, {
|
|
1340
|
+
provider: input.provider,
|
|
1341
|
+
model: input.model
|
|
1342
|
+
}));
|
|
1343
|
+
const customsRaw = fieldOf(doc, "custom_providers");
|
|
1344
|
+
const customs = isRecord(customsRaw) ? customsRaw : {};
|
|
1345
|
+
const existingRaw = fieldOf(customs, input.provider);
|
|
1346
|
+
const entry = {
|
|
1347
|
+
...isRecord(existingRaw) ? existingRaw : {},
|
|
1348
|
+
url: target.baseURL,
|
|
1349
|
+
protocol: target.protocol,
|
|
1350
|
+
model: input.model,
|
|
1351
|
+
api_key_cmd: input.apiKeyCmd
|
|
1352
|
+
};
|
|
1353
|
+
delete entry["api_key"];
|
|
1354
|
+
customs[input.provider] = entry;
|
|
1355
|
+
doc["provider"] = input.provider;
|
|
1356
|
+
doc["custom_providers"] = customs;
|
|
1357
|
+
if (input.language !== void 0) doc["language"] = input.language;
|
|
1358
|
+
return `${JSON.stringify(doc, null, 2)}\n`;
|
|
1359
|
+
}
|
|
1360
|
+
/**
|
|
1361
|
+
* 家目录展开交官方 `expandHomePath`,只有 POSIX 上的 `~\x` 由本地拦下:官方吃
|
|
1362
|
+
* `~/` 与 `~\` 两条前缀,在 POSIX 上会把 `~\notes.txt` 折成 `<home>/\notes.txt`
|
|
1363
|
+
* ——一枚合法的反斜杠文件名,等于把用户打错的相对路径静默变成家目录里的怪文件,
|
|
1364
|
+
* 比现状(报"必须是绝对路径")更坏。原样放行让它落到调用方(lib/ocr-config.ts 的
|
|
1365
|
+
* resolveOcrConfigPath)那道绝对性闸门去报错。
|
|
1366
|
+
* sep 作参数而不是就地读 `path.sep`:两个分支都要能被断言(同 plugin-hot-reload
|
|
1367
|
+
* 的 shellQuoted(bin, viaShell) 手法)。
|
|
1368
|
+
*/
|
|
1369
|
+
function expandHomeForSeparator(value, sep) {
|
|
1370
|
+
if (sep === "/" && value.startsWith("~\\")) return value;
|
|
1371
|
+
return expandHomePath(value);
|
|
1372
|
+
}
|
|
1373
|
+
/** api_key_cmd 脚本在插件目录里的固定落点(host.ts 与兜底路径共用这一处)。 */
|
|
1374
|
+
function defaultCredScriptPath(pluginDir) {
|
|
1375
|
+
return path.join(pluginDir, GET_CRED_SCRIPT);
|
|
1376
|
+
}
|
|
1377
|
+
/** api_key_cmd 脚本落点:显式路径优先,否则按 pluginDir 兜底。 */
|
|
1378
|
+
function credentialScriptPath(paths) {
|
|
1379
|
+
return paths.getCredScript === "" ? defaultCredScriptPath(paths.pluginDir) : paths.getCredScript;
|
|
1380
|
+
}
|
|
1381
|
+
//#endregion
|
|
1382
|
+
//#region lib/ocr-config.ts
|
|
1383
|
+
/** 外部 ocr CLI 的默认配置目录/文件名(宿主服务管不到,只能由本包直读直写)。 */
|
|
1384
|
+
const OCR_CONFIG_DIR = ".opencodereview";
|
|
1385
|
+
const OCR_CONFIG_FILE = "config.json";
|
|
1386
|
+
/** 无凭据可问时的一律 false(卡片据 source 显示原因,不显示假的「未配置 key」)。 */
|
|
1387
|
+
function withoutKeyFlags(providers) {
|
|
1388
|
+
return providers.map((providerCfg) => ({
|
|
1389
|
+
...providerCfg,
|
|
1390
|
+
hasKey: false
|
|
1391
|
+
}));
|
|
1392
|
+
}
|
|
1393
|
+
/**
|
|
1394
|
+
* OCR 配置文件路径:设置项 ocrConfigPath 覆盖,未覆盖时按 os.homedir() 派生。
|
|
1395
|
+
* 家目录展开交官方 `expandHomePath`,但本地保留两条它没有的判据(见
|
|
1396
|
+
* `expandHomeForSeparator` 与下面的绝对性闸门):直写相对路径会把配置落到
|
|
1397
|
+
* 「当时的工作区」,用户找不到也修不了。
|
|
1398
|
+
*/
|
|
1399
|
+
function resolveOcrConfigPath(configured, messages) {
|
|
1400
|
+
const trimmed = typeof configured === "string" ? configured.trim() : "";
|
|
1401
|
+
if (trimmed === "") return path.join(homedir(), OCR_CONFIG_DIR, OCR_CONFIG_FILE);
|
|
1402
|
+
const expanded = expandHomeForSeparator(trimmed, path.sep);
|
|
1403
|
+
if (!path.isAbsolute(expanded)) throw new Error(format(messages.ocrConfigPathInvalid, { path: trimmed }));
|
|
1404
|
+
return expanded;
|
|
1405
|
+
}
|
|
1406
|
+
/**
|
|
1407
|
+
* 组装设置页数据(绝不返回 key 明文;credentials 缺席时 hasKey 全 false 并给出原因,
|
|
1408
|
+
* 让卡片显示「为什么不可用」而不是假装每个 provider 都没配 key)。
|
|
1409
|
+
*/
|
|
1410
|
+
async function providersForCard(gateway, paths, messages) {
|
|
1411
|
+
const { providers, source } = readProviders(gateway, messages);
|
|
1412
|
+
const keyed = source.status === "ready" ? await markKeyFlags(gateway, providers) : withoutKeyFlags(providers);
|
|
1413
|
+
const { current } = loadOcrConfig(readText(paths.ocrConfigJson));
|
|
1414
|
+
return {
|
|
1415
|
+
providers: keyed,
|
|
1416
|
+
current,
|
|
1417
|
+
getCredReady: existsSync(credentialScriptPath(paths)),
|
|
1418
|
+
source
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
/**
|
|
1422
|
+
* 应用选择:官方通道取 provider 清单 → 校验 key 可解析 → 生成新配置 → 写回(0600)。
|
|
1423
|
+
* 任何一步不合条件都抛可读错误(webServer 端点兜成 400,卡片显示原因)。
|
|
1424
|
+
*/
|
|
1425
|
+
async function applyProviderSelection(gateway, paths, selection, messages) {
|
|
1426
|
+
const { provider, model } = selection;
|
|
1427
|
+
const { providers, source } = readProviders(gateway, messages);
|
|
1428
|
+
if (source.status !== "ready") throw new Error(source.message);
|
|
1429
|
+
const target = providers.find((providerCfg) => providerCfg.name === provider);
|
|
1430
|
+
if (!target) throw new Error(format(messages.providerNotInList, {
|
|
1431
|
+
ns: PI_AI_NAMESPACE,
|
|
1432
|
+
provider
|
|
1433
|
+
}));
|
|
1434
|
+
if (target.apiKeyEnv === null || target.apiKeyEnv === "") throw new Error(format(messages.noApiKeyEnv, { provider }));
|
|
1435
|
+
if (!await gateway.credentialResolvable(target.apiKeyEnv)) throw new Error(format(messages.keyNotResolvable, {
|
|
1436
|
+
provider,
|
|
1437
|
+
ref: target.apiKeyEnv
|
|
1438
|
+
}));
|
|
1439
|
+
const scriptPath = credentialScriptPath(paths);
|
|
1440
|
+
if (!existsSync(scriptPath)) throw new Error(format(messages.credScriptMissing, { path: scriptPath }));
|
|
1441
|
+
const apiKeyCmd = buildApiKeyCmd(scriptPath, target.apiKeyEnv, messages);
|
|
1442
|
+
await updateConfigLocked(paths.ocrConfigJson, async () => {
|
|
1443
|
+
const next = renderSelectedConfig({
|
|
1444
|
+
existingText: readText(paths.ocrConfigJson),
|
|
1445
|
+
configLabel: paths.ocrConfigJson,
|
|
1446
|
+
providers,
|
|
1447
|
+
provider,
|
|
1448
|
+
model,
|
|
1449
|
+
apiKeyCmd,
|
|
1450
|
+
...selection.language === void 0 ? {} : { language: selection.language }
|
|
1451
|
+
}, messages);
|
|
1452
|
+
await writeConfigAtomic(paths.ocrConfigJson, next);
|
|
1453
|
+
});
|
|
1454
|
+
return {
|
|
1455
|
+
provider: target.name,
|
|
1456
|
+
model,
|
|
1457
|
+
url: target.baseURL,
|
|
1458
|
+
protocol: target.protocol
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
/**
|
|
1462
|
+
* 迁移单条 custom provider 条目:有明文 api_key 且能找到 envKey → 改写入
|
|
1463
|
+
* api_key_cmd 并删除明文,返回 'migrated';找不到 envKey 或构造命令失败 →
|
|
1464
|
+
* 'unknown';无明文 → 'noPlain'(不动作)。
|
|
1465
|
+
*/
|
|
1466
|
+
function migrateEntry(entry, envKey, scriptPath, messages) {
|
|
1467
|
+
if (typeof entry["api_key"] !== "string" || entry["api_key"].length === 0) return "noPlain";
|
|
1468
|
+
if (envKey === void 0 || envKey === "") return "unknown";
|
|
1469
|
+
try {
|
|
1470
|
+
entry["api_key_cmd"] = buildApiKeyCmd(scriptPath, envKey, messages);
|
|
1471
|
+
} catch {
|
|
1472
|
+
return "unknown";
|
|
1473
|
+
}
|
|
1474
|
+
delete entry["api_key"];
|
|
1475
|
+
return "migrated";
|
|
1476
|
+
}
|
|
1477
|
+
/**
|
|
1478
|
+
* 迁移:把自定义 provider 条目里的明文 api_key 改为 api_key_cmd(动态读凭据)。
|
|
1479
|
+
* api_key_cmd 优先于 api_key,因此清除明文即可。
|
|
1480
|
+
* 对「明文 api_key + 无 api_key_cmd」的条目,依据 settings 里该 provider 的
|
|
1481
|
+
* apiKeyEnv 补充命令;settings 里找不到的 provider 不动(不破坏既有配置),
|
|
1482
|
+
* 在返回信息里说明。credentials 缺席不阻断本操作(它只要 name→apiKeyEnv 映射),
|
|
1483
|
+
* settings 侧读不到则抛错——那正是「不知道该写哪条命令」的状态,静默跳过会让用户
|
|
1484
|
+
* 以为迁移过了。
|
|
1485
|
+
* 返回 { migrated: string[]; skippedUnknown: string[] }。
|
|
1486
|
+
*/
|
|
1487
|
+
async function migratePlaintextKeys(gateway, paths, messages) {
|
|
1488
|
+
const precheck = readText(paths.ocrConfigJson);
|
|
1489
|
+
if (!precheck.trim()) return {
|
|
1490
|
+
migrated: [],
|
|
1491
|
+
skippedUnknown: []
|
|
1492
|
+
};
|
|
1493
|
+
const { providers, source } = readProviders(gateway, messages);
|
|
1494
|
+
if (source.status !== "ready" && source.status !== "credentials-unavailable") throw new Error(source.message);
|
|
1495
|
+
parseExistingConfig(precheck, paths.ocrConfigJson, messages);
|
|
1496
|
+
const scriptPath = credentialScriptPath(paths);
|
|
1497
|
+
if (!existsSync(scriptPath)) return {
|
|
1498
|
+
migrated: [],
|
|
1499
|
+
skippedUnknown: []
|
|
1500
|
+
};
|
|
1501
|
+
const envByProvider = /* @__PURE__ */ new Map();
|
|
1502
|
+
for (const providerCfg of providers) if (providerCfg.apiKeyEnv !== null && providerCfg.apiKeyEnv !== "") envByProvider.set(providerCfg.name, providerCfg.apiKeyEnv);
|
|
1503
|
+
return await updateConfigLocked(paths.ocrConfigJson, async () => {
|
|
1504
|
+
const doc = parseExistingConfig(readText(paths.ocrConfigJson), paths.ocrConfigJson, messages);
|
|
1505
|
+
const customsRaw = fieldOf(doc, "custom_providers");
|
|
1506
|
+
if (!isRecord(customsRaw)) return {
|
|
1507
|
+
migrated: [],
|
|
1508
|
+
skippedUnknown: []
|
|
1509
|
+
};
|
|
1510
|
+
const customs = customsRaw;
|
|
1511
|
+
const migrated = [];
|
|
1512
|
+
const skippedUnknown = [];
|
|
1513
|
+
for (const [name, raw] of Object.entries(customs)) if (isRecord(raw)) {
|
|
1514
|
+
const result = migrateEntry(raw, envByProvider.get(name), scriptPath, messages);
|
|
1515
|
+
if (result === "migrated") migrated.push(name);
|
|
1516
|
+
else if (result === "unknown") skippedUnknown.push(name);
|
|
1517
|
+
}
|
|
1518
|
+
if (migrated.length === 0) return {
|
|
1519
|
+
migrated,
|
|
1520
|
+
skippedUnknown
|
|
1521
|
+
};
|
|
1522
|
+
await writeConfigAtomic(paths.ocrConfigJson, `${JSON.stringify(doc, null, 2)}\n`);
|
|
1523
|
+
return {
|
|
1524
|
+
migrated,
|
|
1525
|
+
skippedUnknown
|
|
1526
|
+
};
|
|
1527
|
+
});
|
|
1528
|
+
}
|
|
1529
|
+
//#endregion
|
|
1530
|
+
//#region host.ts
|
|
1531
|
+
const PLUGIN_DIR = import.meta.dirname;
|
|
1532
|
+
const MAX_TIMEOUT_REQUEST_MS = 2147483647;
|
|
1533
|
+
const LLM_TEST_TIMEOUT_MS = 9e4;
|
|
1534
|
+
const STDOUT_MAX_BYTES = 4e5;
|
|
1535
|
+
/** 解析失败时回显给模型的原始内容长度上限(UTF-16 码元;已脱敏)。
|
|
1536
|
+
* 单位写码元而不是"字符":本常量喂给 `echoRaw`→`truncateEnd`,那一刀按码元数计
|
|
1537
|
+
* (一张 emoji = 2 枚),写"字符"会把给模型的说明夸大约一倍。 */
|
|
1538
|
+
const RAW_ECHO_LIMIT = 500;
|
|
1539
|
+
const BODY_MAX_BYTES = 8192;
|
|
1540
|
+
/** 评论截断条数的内置默认(旧 BUILTIN_BASE 的那一项):schema 的 `.default()` 与
|
|
1541
|
+
* 「读到坏值时的回退」(readMaxComments)共用这一个数,两处不许各写一份字面量。 */
|
|
1542
|
+
const MAX_COMMENTS_DEFAULT = 12;
|
|
1543
|
+
/** 会话内 CSRF token 头名(与 lesson-loop/zvec-grep 同族命名)。 */
|
|
1544
|
+
const OCR_CSRF_HEADER = "x-ocr-csrf";
|
|
1545
|
+
const PROVIDERS_PATH = "/_dsh/ocr-review/providers";
|
|
1546
|
+
const SELECT_PATH = "/_dsh/ocr-review/select";
|
|
1547
|
+
const MIGRATE_PATH = "/_dsh/ocr-review/migrate";
|
|
1548
|
+
const TEST_PATH = "/_dsh/ocr-review/test";
|
|
1549
|
+
/** 官方注册表里本包作业的 kind;宿主据此签发的 id 形如 `ocr-review-N`。 */
|
|
1550
|
+
const OCR_JOB_KIND = "ocr-review";
|
|
1551
|
+
/**
|
|
1552
|
+
* 后台评审的兜底上限。本包刻意不套宿主 timeoutMs(`onExpiry: "none"`:外部 CLI 的正常
|
|
1553
|
+
* 时长跨度太大,套了就等于替用户砍掉长评审),但换装后"完全没有终点"有了新的受害者——
|
|
1554
|
+
* 未拥有作业共用一只 10 格的桶(实测 `running` 与 `stopping` 都计数,满员是**拒绝**
|
|
1555
|
+
* 后来的 start),一条挂死的 CLI 会永久占一格,连别的插件也起不来。2 小时远超正常评审,
|
|
1556
|
+
* 到点经注册表收:记录推到 stopping,理由落进模型可见的 detail。
|
|
1557
|
+
*/
|
|
1558
|
+
const OCR_BACKGROUND_MAX_MS = 72e5;
|
|
1559
|
+
/** "仍在跑"的两枚官方状态:`stopping` 是取消已发出、进程还没收完。 */
|
|
1560
|
+
function isLiveOcrStatus(status) {
|
|
1561
|
+
return status === "running" || status === "stopping";
|
|
1562
|
+
}
|
|
1563
|
+
/**
|
|
1564
|
+
* 剪掉本 kind 最老的已落定记录,给下一次登记腾出窗口(注册表按登记序返回,故取头部)。
|
|
1565
|
+
* 在跑与正在收的一条不动:官方件对活作业的 remove 直接抛,而"静默杀掉用户发起的评审"
|
|
1566
|
+
* 正是这次换装要消灭的形态。
|
|
1567
|
+
* @param jobs 官方注册表
|
|
1568
|
+
* @returns {void}
|
|
1569
|
+
*/
|
|
1570
|
+
function pruneOcrJobHistory(jobs) {
|
|
1571
|
+
const settled = jobs.list().filter((job) => job.kind === OCR_JOB_KIND && !isLiveOcrStatus(job.status));
|
|
1572
|
+
const excess = settled.length - 9;
|
|
1573
|
+
for (const job of settled.slice(0, Math.max(excess, 0))) jobs.remove(job.id);
|
|
1574
|
+
}
|
|
1575
|
+
/**
|
|
1576
|
+
* 在 controller 闸门下起一条未拥有作业,起完立刻摘掉。
|
|
1577
|
+
*
|
|
1578
|
+
* 实测:挂上即放行、摘掉即恢复"没有 controller 服务这个 owner"的拒绝、可反复;而 global 层
|
|
1579
|
+
* 那串 token 只被 `servesOwner` 读(installed dsh-jobs-local 全文唯一消费点),作业后续的
|
|
1580
|
+
* read/kill/落定都不依赖它。所以常驻一枚等于在"宿主故意没装 job 工具"的组成里替全宿主
|
|
1581
|
+
* 开着那道闸门——官方契约写的是"生产者不能起一台 owner 收不掉、停不掉的作业"。
|
|
1582
|
+
*/
|
|
1583
|
+
function startUnderOcrController(jobs, spec) {
|
|
1584
|
+
const detach = jobs.attachController("ocr-review: background");
|
|
1585
|
+
try {
|
|
1586
|
+
return jobs.start(spec);
|
|
1587
|
+
} finally {
|
|
1588
|
+
detach();
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
/**
|
|
1592
|
+
* 等到兜底 deadline 到点,然后把仍在跑的那条经注册表收掉。
|
|
1593
|
+
*
|
|
1594
|
+
* 用注册表自己的 `wait(id, ms)` 而不是宿主 timer,也不是裸 `setTimeout`:前者要把
|
|
1595
|
+
* `timer` 写进 HostCtx 的**必需**服务面(装载守卫一扩,没装那件的宿主连前台评审都装载
|
|
1596
|
+
* 不了),后者会活过插件卸载。实测 `wait` 只等不杀、作业一落定就解掉它内部的定时器,
|
|
1597
|
+
* 也不推进输出游标(模型侧 job_output 的读数不受影响)——所以落定时这里看一眼状态就返回。
|
|
1598
|
+
* @param jobs 官方注册表
|
|
1599
|
+
* @param id 这次后台评审的作业 id
|
|
1600
|
+
* @param reason 到点回收时写进模型可见 detail 的理由
|
|
1601
|
+
* @returns 等完这一条的兜底窗口;抛错一律就地收敛(记录没了就等于无事可做)
|
|
1602
|
+
*/
|
|
1603
|
+
async function enforceOcrDeadline(jobs, id, reason, backgroundMaxMs) {
|
|
1604
|
+
try {
|
|
1605
|
+
if ((await jobs.wait(id, backgroundMaxMs)).status === "running") jobs.kill(id, void 0, reason);
|
|
1606
|
+
} catch {}
|
|
1607
|
+
}
|
|
1608
|
+
/**
|
|
1609
|
+
* 卸载时收掉本包仍在跑(或正在收尾)的作业。
|
|
1610
|
+
*
|
|
1611
|
+
* 注册表随宿主活着,不会因为我们卸载而杀进程:一条留在 `running` 的 OCR 既继续占着未拥有
|
|
1612
|
+
* 那一桶的容量位(实测满员是拒绝别人的 start),又没人再等它的输出。遍历的是注册表的
|
|
1613
|
+
* `list()` 而不是本包记的账——本包不留常驻状态,跨会话/跨 fiber 起的作业也要能被收掉。
|
|
1614
|
+
* @param jobs 官方注册表
|
|
1615
|
+
* @param reason 写进模型可见 detail 的理由(双语字典取)
|
|
1616
|
+
* @returns {void}
|
|
1617
|
+
*/
|
|
1618
|
+
function reclaimOcrJobs(jobs, reason) {
|
|
1619
|
+
const mine = jobs.list().filter((job) => job.kind === OCR_JOB_KIND);
|
|
1620
|
+
for (const job of mine) if (isLiveOcrStatus(job.status)) jobs.kill(job.id, void 0, reason);
|
|
1621
|
+
else jobs.remove(job.id);
|
|
1622
|
+
}
|
|
1623
|
+
/** 当前会话工作区(repo 缺省回退源);非字符串视为缺。
|
|
1624
|
+
* 官方形状:`agent?: Agent` → `session: Session` → `header: SessionHeader` →
|
|
1625
|
+
* `cwd?: string`。下面的逐层 `?.` 与 `typeof` 复核是跨进程契约的运行时闸:宿主半装配
|
|
1626
|
+
* 递来的 agent 可能是 `null`、也可能没有 `session`/`header`(test/host.test.ts 钉住
|
|
1627
|
+
* 这两条 fail-closed 路径),值一律经 `unknown` 读,绝不只信声明。 */
|
|
1628
|
+
function sessionHeaderCwd(exec) {
|
|
1629
|
+
const raw = exec.agent?.session?.header?.cwd;
|
|
1630
|
+
return typeof raw === "string" && raw.length > 0 ? raw : void 0;
|
|
1631
|
+
}
|
|
1632
|
+
/** 服务面校验:对象存在且声明的方法全部可调用。
|
|
1633
|
+
* 只做 `typeof value["shell"] === "object"` 的话,守卫声称的是「有 shell 服务」
|
|
1634
|
+
* 而实际证明的只是「有个对象」——运行到一半才炸。逐个点名方法,守卫才名副其实
|
|
1635
|
+
* (HostCtx 用到的每个方法都在这里被验证过一遍)。 */
|
|
1636
|
+
function hasMethods(value, methods) {
|
|
1637
|
+
if (!isRecord(value)) return false;
|
|
1638
|
+
for (const method of methods) if (typeof value[method] !== "function") return false;
|
|
1639
|
+
return true;
|
|
1640
|
+
}
|
|
1641
|
+
/** host 是否为本插件所需服务面(settings/shell/tools/systemPrompt/get/inject 及
|
|
1642
|
+
* 其上要调用的方法)。守卫仅压缩类型面、避开 `as unknown as HostCtx` 断言。
|
|
1643
|
+
* settings 这里点名的两个方法都会被调用:configure 登记本页的页面策略、describe
|
|
1644
|
+
* 跨命名空间读(provider 清单 + 官方 locale 偏好);inject 挂那条页面策略的 effect。
|
|
1645
|
+
* 0.1.7 起 settings 再无 register/get,多点名一个就是永不成立的死分支。 */
|
|
1646
|
+
function isOcrReviewHost(value) {
|
|
1647
|
+
if (!isRecord(value)) return false;
|
|
1648
|
+
const { settings, shell, tools, systemPrompt, inject, get } = value;
|
|
1649
|
+
return hasMethods(settings, ["configure", "describe"]) && hasMethods(shell, ["resolve", "execute"]) && hasMethods(tools, ["register"]) && hasMethods(systemPrompt, ["section"]) && typeof inject === "function" && typeof get === "function";
|
|
1650
|
+
}
|
|
1651
|
+
/** 从插件 Context 解析宿主服务面(守卫收窄,不做 `as unknown as` 断言)。 */
|
|
1652
|
+
function resolveHost(ctx) {
|
|
1653
|
+
if (!isOcrReviewHost(ctx)) throw new Error("ocr-review: 宿主缺少所需的 shell/tools/settings/systemPrompt/inject 服务界面(或其方法)");
|
|
1654
|
+
return ctx;
|
|
1655
|
+
}
|
|
1656
|
+
/** describe() 行里按命名空间取解析值(0.1.7 的跨命名空间读只有 describe() 一条路:
|
|
1657
|
+
* `settings.get(ns)` 已删)。未注册该命名空间 → undefined,调用侧按默认值降级。
|
|
1658
|
+
* @param rows - `ctx.settings.describe()` 的返回(全部条目的表单投影)。
|
|
1659
|
+
* @param ns - 要读的命名空间(官方字段名是 `ns`,上游有改名 namespace 的 TODO)。
|
|
1660
|
+
*/
|
|
1661
|
+
function describedValue(rows, ns) {
|
|
1662
|
+
return rows.find((row) => row.ns === ns)?.value;
|
|
1663
|
+
}
|
|
1664
|
+
/**
|
|
1665
|
+
* 取本包 host 侧文案(按官方 locale 偏好现取现用):设置页「常规」里换语言后,
|
|
1666
|
+
* 下一次工具调用/端点请求即换新文案,不需要重启或重装插件。
|
|
1667
|
+
* locale 命名空间未注册(无官方 client-locale 包)→ describe() 里没有这条 →
|
|
1668
|
+
* undefined → 中文默认。
|
|
1669
|
+
*/
|
|
1670
|
+
function localeMessages(host) {
|
|
1671
|
+
const preference = describedValue(host.settings.describe(), LOCALE_SETTINGS_NAMESPACE);
|
|
1672
|
+
return messagesFor(MESSAGES, resolveLocalePreference(preference));
|
|
1673
|
+
}
|
|
1674
|
+
/**
|
|
1675
|
+
* render 只做 model-facing 展示:把 canonical value 本身 pretty-print 成文本块。
|
|
1676
|
+
* execute 直返结构化对象(不再 stringify 进 `{ text }`),所以这里 stringify 的是
|
|
1677
|
+
* 第一层也是唯一一层 JSON;循环结构/不可序列化值的两级兜底是防御,不是常规路径。
|
|
1678
|
+
*/
|
|
1679
|
+
function textBlock(value) {
|
|
1680
|
+
let text;
|
|
1681
|
+
try {
|
|
1682
|
+
const json = JSON.stringify(value, null, 2);
|
|
1683
|
+
text = typeof json === "string" ? json : String(value);
|
|
1684
|
+
} catch {
|
|
1685
|
+
try {
|
|
1686
|
+
text = String(value);
|
|
1687
|
+
} catch {
|
|
1688
|
+
text = "[unrenderable value]";
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
return [{
|
|
1692
|
+
type: "text",
|
|
1693
|
+
text
|
|
1694
|
+
}];
|
|
1695
|
+
}
|
|
1696
|
+
/** 可空 string 字段的 schema 片段(官方子集单类型,null 分支用 oneOf 表达)。 */
|
|
1697
|
+
const NULLABLE_STRING = { oneOf: [{ type: "string" }, { type: "null" }] };
|
|
1698
|
+
/** ocr_review / ocr_scan 的输出:wait=false = 后台回执对象;前台成功 = 摘要对象;
|
|
1699
|
+
* 前台解析失败 = 一段诚实的说明字符串。三者恰一(required 逐字段声明保证两个
|
|
1700
|
+
* 对象分支互斥)。 */
|
|
1701
|
+
const REVIEW_OUTPUT_SCHEMA = { oneOf: [
|
|
1702
|
+
{
|
|
1703
|
+
type: "object",
|
|
1704
|
+
additionalProperties: false,
|
|
1705
|
+
required: [
|
|
1706
|
+
"started",
|
|
1707
|
+
"mode",
|
|
1708
|
+
"tool",
|
|
1709
|
+
"repo",
|
|
1710
|
+
"processStatus",
|
|
1711
|
+
"polling"
|
|
1712
|
+
],
|
|
1713
|
+
properties: {
|
|
1714
|
+
started: {
|
|
1715
|
+
type: "boolean",
|
|
1716
|
+
const: true
|
|
1717
|
+
},
|
|
1718
|
+
mode: {
|
|
1719
|
+
type: "string",
|
|
1720
|
+
const: "background"
|
|
1721
|
+
},
|
|
1722
|
+
tool: { type: "string" },
|
|
1723
|
+
repo: { type: "string" },
|
|
1724
|
+
processStatus: {
|
|
1725
|
+
type: "string",
|
|
1726
|
+
enum: [
|
|
1727
|
+
"running",
|
|
1728
|
+
"completed",
|
|
1729
|
+
"killed"
|
|
1730
|
+
]
|
|
1731
|
+
},
|
|
1732
|
+
polling: { type: "string" }
|
|
1733
|
+
}
|
|
1734
|
+
},
|
|
1735
|
+
{
|
|
1736
|
+
type: "object",
|
|
1737
|
+
additionalProperties: false,
|
|
1738
|
+
required: [
|
|
1739
|
+
"status",
|
|
1740
|
+
"skipped",
|
|
1741
|
+
"message",
|
|
1742
|
+
"summary",
|
|
1743
|
+
"sessionId",
|
|
1744
|
+
"aggregation",
|
|
1745
|
+
"totalCommentCount",
|
|
1746
|
+
"droppedCount",
|
|
1747
|
+
"invalidCommentCount",
|
|
1748
|
+
"topComments",
|
|
1749
|
+
"warnings",
|
|
1750
|
+
"hint"
|
|
1751
|
+
],
|
|
1752
|
+
properties: {
|
|
1753
|
+
status: {
|
|
1754
|
+
type: "string",
|
|
1755
|
+
enum: [
|
|
1756
|
+
"success",
|
|
1757
|
+
"complete",
|
|
1758
|
+
"completed_with_warnings",
|
|
1759
|
+
"completed_with_errors",
|
|
1760
|
+
"skipped"
|
|
1761
|
+
]
|
|
1762
|
+
},
|
|
1763
|
+
skipped: { type: "boolean" },
|
|
1764
|
+
message: NULLABLE_STRING,
|
|
1765
|
+
summary: { oneOf: [{ type: "null" }, {
|
|
1766
|
+
type: "object",
|
|
1767
|
+
additionalProperties: false,
|
|
1768
|
+
required: [
|
|
1769
|
+
"filesReviewed",
|
|
1770
|
+
"comments",
|
|
1771
|
+
"totalTokens",
|
|
1772
|
+
"inputTokens",
|
|
1773
|
+
"outputTokens",
|
|
1774
|
+
"elapsed"
|
|
1775
|
+
],
|
|
1776
|
+
properties: {
|
|
1777
|
+
filesReviewed: { type: "number" },
|
|
1778
|
+
comments: { type: "number" },
|
|
1779
|
+
totalTokens: { type: "number" },
|
|
1780
|
+
inputTokens: { type: "number" },
|
|
1781
|
+
outputTokens: { type: "number" },
|
|
1782
|
+
elapsed: { type: "string" }
|
|
1783
|
+
}
|
|
1784
|
+
}] },
|
|
1785
|
+
sessionId: NULLABLE_STRING,
|
|
1786
|
+
aggregation: {
|
|
1787
|
+
type: "object",
|
|
1788
|
+
additionalProperties: false,
|
|
1789
|
+
required: [
|
|
1790
|
+
"total",
|
|
1791
|
+
"bySeverity",
|
|
1792
|
+
"byCategory"
|
|
1793
|
+
],
|
|
1794
|
+
properties: {
|
|
1795
|
+
total: { type: "integer" },
|
|
1796
|
+
bySeverity: {
|
|
1797
|
+
type: "object",
|
|
1798
|
+
additionalProperties: false,
|
|
1799
|
+
required: [
|
|
1800
|
+
"critical",
|
|
1801
|
+
"high",
|
|
1802
|
+
"medium",
|
|
1803
|
+
"low"
|
|
1804
|
+
],
|
|
1805
|
+
properties: {
|
|
1806
|
+
critical: { type: "integer" },
|
|
1807
|
+
high: { type: "integer" },
|
|
1808
|
+
medium: { type: "integer" },
|
|
1809
|
+
low: { type: "integer" }
|
|
1810
|
+
}
|
|
1811
|
+
},
|
|
1812
|
+
byCategory: { type: "object" }
|
|
1813
|
+
}
|
|
1814
|
+
},
|
|
1815
|
+
totalCommentCount: { type: "integer" },
|
|
1816
|
+
droppedCount: { type: "integer" },
|
|
1817
|
+
invalidCommentCount: { type: "integer" },
|
|
1818
|
+
topComments: {
|
|
1819
|
+
type: "array",
|
|
1820
|
+
items: {
|
|
1821
|
+
type: "object",
|
|
1822
|
+
additionalProperties: false,
|
|
1823
|
+
required: [
|
|
1824
|
+
"location",
|
|
1825
|
+
"severity",
|
|
1826
|
+
"category",
|
|
1827
|
+
"content",
|
|
1828
|
+
"suggestion"
|
|
1829
|
+
],
|
|
1830
|
+
properties: {
|
|
1831
|
+
location: { type: "string" },
|
|
1832
|
+
severity: { type: "string" },
|
|
1833
|
+
category: { type: "string" },
|
|
1834
|
+
content: { type: "string" },
|
|
1835
|
+
suggestion: NULLABLE_STRING
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
},
|
|
1839
|
+
warnings: {
|
|
1840
|
+
type: "array",
|
|
1841
|
+
items: { type: "string" }
|
|
1842
|
+
},
|
|
1843
|
+
hint: { type: "string" }
|
|
1844
|
+
}
|
|
1845
|
+
},
|
|
1846
|
+
{ type: "string" }
|
|
1847
|
+
] };
|
|
1848
|
+
/** ocr_delegate_preview:成功 = 清单对象;解析失败 = 说明字符串。 */
|
|
1849
|
+
const PREVIEW_OUTPUT_SCHEMA = { oneOf: [{
|
|
1850
|
+
type: "object",
|
|
1851
|
+
additionalProperties: false,
|
|
1852
|
+
required: [
|
|
1853
|
+
"mode",
|
|
1854
|
+
"repository",
|
|
1855
|
+
"reviewableCount",
|
|
1856
|
+
"insertions",
|
|
1857
|
+
"deletions",
|
|
1858
|
+
"reviewableFiles",
|
|
1859
|
+
"excludedFiles",
|
|
1860
|
+
"hint"
|
|
1861
|
+
],
|
|
1862
|
+
properties: {
|
|
1863
|
+
mode: { type: "string" },
|
|
1864
|
+
repository: { type: "string" },
|
|
1865
|
+
reviewableCount: { type: "integer" },
|
|
1866
|
+
insertions: { type: "number" },
|
|
1867
|
+
deletions: { type: "number" },
|
|
1868
|
+
reviewableFiles: {
|
|
1869
|
+
type: "array",
|
|
1870
|
+
items: {
|
|
1871
|
+
type: "object",
|
|
1872
|
+
additionalProperties: false,
|
|
1873
|
+
required: [
|
|
1874
|
+
"path",
|
|
1875
|
+
"status",
|
|
1876
|
+
"insertions",
|
|
1877
|
+
"deletions"
|
|
1878
|
+
],
|
|
1879
|
+
properties: {
|
|
1880
|
+
path: { type: "string" },
|
|
1881
|
+
status: { type: "string" },
|
|
1882
|
+
insertions: { type: "number" },
|
|
1883
|
+
deletions: { type: "number" }
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
},
|
|
1887
|
+
excludedFiles: {
|
|
1888
|
+
type: "array",
|
|
1889
|
+
items: {
|
|
1890
|
+
type: "object",
|
|
1891
|
+
additionalProperties: false,
|
|
1892
|
+
required: ["path", "reason"],
|
|
1893
|
+
properties: {
|
|
1894
|
+
path: { type: "string" },
|
|
1895
|
+
reason: { type: "string" }
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
},
|
|
1899
|
+
hint: { type: "string" }
|
|
1900
|
+
}
|
|
1901
|
+
}, { type: "string" }] };
|
|
1902
|
+
/** ocr_delegate_rule:成功 = 分组对象;解析失败 = 说明字符串。 */
|
|
1903
|
+
const RULES_OUTPUT_SCHEMA = { oneOf: [{
|
|
1904
|
+
type: "object",
|
|
1905
|
+
additionalProperties: false,
|
|
1906
|
+
required: [
|
|
1907
|
+
"groupCount",
|
|
1908
|
+
"groups",
|
|
1909
|
+
"hint"
|
|
1910
|
+
],
|
|
1911
|
+
properties: {
|
|
1912
|
+
groupCount: { type: "integer" },
|
|
1913
|
+
groups: {
|
|
1914
|
+
type: "array",
|
|
1915
|
+
items: {
|
|
1916
|
+
type: "object",
|
|
1917
|
+
additionalProperties: false,
|
|
1918
|
+
required: [
|
|
1919
|
+
"source",
|
|
1920
|
+
"pattern",
|
|
1921
|
+
"files",
|
|
1922
|
+
"rule"
|
|
1923
|
+
],
|
|
1924
|
+
properties: {
|
|
1925
|
+
source: { type: "string" },
|
|
1926
|
+
pattern: { type: "string" },
|
|
1927
|
+
files: {
|
|
1928
|
+
type: "array",
|
|
1929
|
+
items: { type: "string" }
|
|
1930
|
+
},
|
|
1931
|
+
rule: { type: "string" }
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
},
|
|
1935
|
+
hint: { type: "string" }
|
|
1936
|
+
}
|
|
1937
|
+
}, { type: "string" }] };
|
|
1938
|
+
/** ocr_session:sessionOutput 本身就是一手透传的字符串(诚实的值),外层仍是对象
|
|
1939
|
+
* 好让 hint 有处安放。 */
|
|
1940
|
+
const SESSION_OUTPUT_SCHEMA = {
|
|
1941
|
+
type: "object",
|
|
1942
|
+
additionalProperties: false,
|
|
1943
|
+
required: ["sessionOutput", "hint"],
|
|
1944
|
+
properties: {
|
|
1945
|
+
sessionOutput: { type: "string" },
|
|
1946
|
+
hint: { type: "string" }
|
|
1947
|
+
}
|
|
1948
|
+
};
|
|
1949
|
+
/** 失败详情:stderr 优先、空则退 stdout;先脱敏再截断(明文 key 绝不进工具错误
|
|
1950
|
+
* ——错误文本会被持久化进会话日志)。省略标记是文案,故脱敏与截断的位置不变,
|
|
1951
|
+
* 只把中间的连接标记换成当前语言的那一份。两半各用一个函数,因为它们是两种缺陷:
|
|
1952
|
+
* 前切点会留下孤立**高**代理(`truncateEnd` 丢掉它),后切点会留下孤立**低**代理
|
|
1953
|
+
* (`truncateStart` 丢掉它)。把两个函数对调同样不留孤立代理,只是头尾内容掉头
|
|
1954
|
+
* ——故 test/host.test.ts 里另有一组哨兵断言钉住顺序。 */
|
|
1955
|
+
function failureDetail(result, messages) {
|
|
1956
|
+
const detail = redactKeyMaterial(result.stderr.text === "" ? result.stdout.text : result.stderr.text);
|
|
1957
|
+
return detail.length <= 800 ? detail : `${truncateEnd(detail, 400)}${messages.middleEllipsis}${truncateStart(detail, 400)}`;
|
|
1958
|
+
}
|
|
1959
|
+
/** 前台执行:失败抛错(含给模型的行动提示),成功返回 stdout 文本。
|
|
1960
|
+
* timeoutMs 恒有值(tuningOf(config)),宿主按 min(请求, shell.maxTimeoutMs) 收口。
|
|
1961
|
+
* signal 必传:harness ToolExecution.signal 是必填的调用方取消权。 */
|
|
1962
|
+
async function runForeground(host, command, workdir, run, signal) {
|
|
1963
|
+
const messages = localeMessages(host);
|
|
1964
|
+
const spec = host.shell.resolve({
|
|
1965
|
+
command,
|
|
1966
|
+
workdir,
|
|
1967
|
+
timeoutMs: run.timeoutMs,
|
|
1968
|
+
stdoutMaxBytes: run.stdoutMaxBytes,
|
|
1969
|
+
signal
|
|
1970
|
+
});
|
|
1971
|
+
const result = await (await host.shell.execute(spec)).result();
|
|
1972
|
+
if (result.exitCode !== 0 || result.timedOut || result.aborted) {
|
|
1973
|
+
if (result.timedOut) {
|
|
1974
|
+
const minutes = Math.round(run.timeoutMs / 6e4);
|
|
1975
|
+
throw new Error(format(messages.execTimedOut, { minutes: String(minutes) }));
|
|
1976
|
+
}
|
|
1977
|
+
if (result.aborted) throw new Error(messages.execAborted);
|
|
1978
|
+
if (result.exitCode === 127) throw new Error(messages.execNotFound);
|
|
1979
|
+
const detail = failureDetail(result, messages);
|
|
1980
|
+
const head = format(messages.execFailed, { exitCode: String(result.exitCode) });
|
|
1981
|
+
throw new Error(detail === "" ? head : `${head}${messages.detailSeparator}${detail}`);
|
|
1982
|
+
}
|
|
1983
|
+
const { text, truncated, spillPath } = result.stdout;
|
|
1984
|
+
if (truncated) {
|
|
1985
|
+
const spill = typeof spillPath === "string" ? format(messages.outputSpill, { path: spillPath }) : "";
|
|
1986
|
+
return `${text}\n${format(messages.stdoutTruncated, {
|
|
1987
|
+
limit: String(run.stdoutMaxBytes),
|
|
1988
|
+
spill
|
|
1989
|
+
})}`;
|
|
1990
|
+
}
|
|
1991
|
+
return text;
|
|
1992
|
+
}
|
|
1993
|
+
/**
|
|
1994
|
+
* 终止 OCR 进程,忽略"已经没有必要杀"的那一类异常(进程自然退出、已被注册表或宿主回收)。
|
|
1995
|
+
*
|
|
1996
|
+
* 这层兜不是风格:官方契约明写生产者的 `cancel` 会往上抛(installed
|
|
1997
|
+
* `dsh-jobs/lib/types/types.d.ts:98-102` "throws propagate"),而 `killJob` 裸调
|
|
1998
|
+
* `job.cancel(reason)`、只有 teardown 那一臂逐条 try/catch(同包 `lib/index.js:611-622`
|
|
1999
|
+
* 对照 `:805-824`)。本包的 `cancel` 是从 `AbortSignal` 监听器里被回调的,Node 对监听器
|
|
2000
|
+
* 抛出的处理是 `process.nextTick(() => { throw err })` ⇒ **未捕获异常、宿主进程退出**
|
|
2001
|
+
* (本机实跑复现)。一次取消杀掉整个 dsh 不是可接受的失败形态。
|
|
2002
|
+
*/
|
|
2003
|
+
function killProc(proc) {
|
|
2004
|
+
try {
|
|
2005
|
+
proc.kill();
|
|
2006
|
+
} catch {}
|
|
2007
|
+
}
|
|
2008
|
+
/**
|
|
2009
|
+
* 进程的两条流作为注册表 pull source(与宿主 bash 工具的 processSources 同一形状)。
|
|
2010
|
+
* 交给注册表按它自己的节奏泵 ⇒ 没人来看时输出也照样进环;dsh-shell 的 `observed` 是
|
|
2011
|
+
* **非消费**的按偏移读,与 `readOutput()` 的游标互不争夺(installed
|
|
2012
|
+
* dsh-shell/lib/types/types.d.ts:196-203)。
|
|
2013
|
+
*
|
|
2014
|
+
* 外面再兜一道:官方泵对"源抛错"的答法是**此后不再排这一路**(实测——作业照样落定,
|
|
2015
|
+
* 但后续增量一条都不进环,环上一个标记也没有)。所以这里把抛错翻译成"游标原地不动 +
|
|
2016
|
+
* 一次性说明":读得回来时下一拍照常续读,读不回来模型至少知道这段不可靠。说明只写一次,
|
|
2017
|
+
* 免得每拍 150ms 把同一句话灌进环里。
|
|
2018
|
+
*/
|
|
2019
|
+
function reviewSources(proc, messages) {
|
|
2020
|
+
const source = (channel) => {
|
|
2021
|
+
let failed = false;
|
|
2022
|
+
return {
|
|
2023
|
+
channel,
|
|
2024
|
+
read: (fromByte) => {
|
|
2025
|
+
if (failed) return {
|
|
2026
|
+
text: "",
|
|
2027
|
+
nextOffset: fromByte,
|
|
2028
|
+
lossy: true
|
|
2029
|
+
};
|
|
2030
|
+
try {
|
|
2031
|
+
return proc.observed[channel].readFrom(fromByte);
|
|
2032
|
+
} catch {
|
|
2033
|
+
failed = true;
|
|
2034
|
+
return {
|
|
2035
|
+
text: messages.jobOutputGap,
|
|
2036
|
+
nextOffset: fromByte,
|
|
2037
|
+
lossy: true
|
|
2038
|
+
};
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
};
|
|
2042
|
+
};
|
|
2043
|
+
return [source("stdout"), source("stderr")];
|
|
2044
|
+
}
|
|
2045
|
+
/**
|
|
2046
|
+
* 作业生产者的 `done`:等子进程落定,再把终态折算成注册表结局。
|
|
2047
|
+
* 注册表在 `start` 那一刻就要拿到这个 Promise,所以由 async 函数现造一个,
|
|
2048
|
+
* 而不是在 spec 字面量里挂 `.then`(本仓口径见 lint 基线 `promise/prefer-await-to-then`)。
|
|
2049
|
+
*/
|
|
2050
|
+
async function outcomeWhenSettled(proc) {
|
|
2051
|
+
await proc.done;
|
|
2052
|
+
return jobOutcomeOf(proc);
|
|
2053
|
+
}
|
|
2054
|
+
/** 子进程落定后摘掉 abort 监听:fire-and-forget 的一臂,等的是收尾而不是启动。 */
|
|
2055
|
+
async function detachWhenSettled(proc, detach) {
|
|
2056
|
+
await proc.done;
|
|
2057
|
+
detach();
|
|
2058
|
+
}
|
|
2059
|
+
/**
|
|
2060
|
+
* 后台启动:resolve 显式带 `onExpiry: "none"`,故不套宿主 deadline(0.1.7 起
|
|
2061
|
+
* bash-local 缺省是 'kill',不传就会被缺省 timeoutMs 杀掉),启动准备完成即返回,不等
|
|
2062
|
+
* 运行结束、不写 --output。结果以 OCR 会话记录为准(增量落盘),由模型经
|
|
2063
|
+
* ocr_session 工具轮询。dsh 无论优雅退出(teardown 组 TERM→KILL)还是硬退出
|
|
2064
|
+
* (kill -9/崩溃后被 launchd 收养),命令里的宿主回收守护(cli.ts
|
|
2065
|
+
* wrapWithHostReaper)都会把 OCR 一并结束,不留孤儿进程烧 token。
|
|
2066
|
+
*
|
|
2067
|
+
* 那次子进程现在同时是一枚**官方作业**(kind `ocr-review`):官方 `job_list` /
|
|
2068
|
+
* `job_output` / `job_kill` 从此看得见它,本包不再需要为"起完就没人管"负责。
|
|
2069
|
+
* 工具的返回值是下面的结构化回执对象(canonical 值直返;作业面换装只动了"谁能看见
|
|
2070
|
+
* 并停掉它",这份回执的内容自此未变——变的只是不再包一层 `{ text }` 字符串)。
|
|
2071
|
+
*
|
|
2072
|
+
* 取消语义(此前句柄被直接丢弃 → 工具调用被中止后外部 CLI 仍在跑):
|
|
2073
|
+
* - spec 带 signal:取消落在「启动准备阶段」时由宿主直接拒绝启动;
|
|
2074
|
+
* - 已拿到句柄后 abort:经注册表 `kill(id, undefined, reason)` 收,它把记录推到
|
|
2075
|
+
* stopping、把 reason 落进 detail,再回调本包的 `cancel()` 去杀进程;
|
|
2076
|
+
* - 进程自然落定(done 永不 reject)后摘掉 abort 监听,不在长会话里堆监听器。
|
|
2077
|
+
* `kill` 的 reason 只在这一处带:注册表在 killed 时会把同一个 reason 追加进 detail,
|
|
2078
|
+
* 生产者的 `done` 结局再带一遍就是把同一句写两遍(`jobOutcomeOf` 因此刻意不带)。
|
|
2079
|
+
*
|
|
2080
|
+
* @param host 宿主服务面
|
|
2081
|
+
* @param job 这次后台评审的描述
|
|
2082
|
+
* @param signal 工具调用的中止信号
|
|
2083
|
+
* @returns 后台启动回执(canonical 对象,模型侧契约)
|
|
2084
|
+
*/
|
|
2085
|
+
async function startBackground(host, job, signal) {
|
|
2086
|
+
const registry = host.get("jobs");
|
|
2087
|
+
if (registry === void 0) throw new Error("background review unavailable: the host provides no ctx.jobs (load @deepseek-ai/dsh-jobs-local); run it with wait:true instead");
|
|
2088
|
+
const spec = host.shell.resolve({
|
|
2089
|
+
command: job.command,
|
|
2090
|
+
workdir: job.workdir,
|
|
2091
|
+
onExpiry: "none",
|
|
2092
|
+
stdoutMaxBytes: job.stdoutMaxBytes,
|
|
2093
|
+
signal
|
|
2094
|
+
});
|
|
2095
|
+
const proc = await host.shell.execute(spec);
|
|
2096
|
+
pruneOcrJobHistory(registry);
|
|
2097
|
+
const messages = localeMessages(host);
|
|
2098
|
+
let jobId;
|
|
2099
|
+
try {
|
|
2100
|
+
jobId = startUnderOcrController(registry, {
|
|
2101
|
+
kind: OCR_JOB_KIND,
|
|
2102
|
+
label: `${job.tool} ${job.repo}`,
|
|
2103
|
+
output: reviewSources(proc, messages),
|
|
2104
|
+
run: () => ({
|
|
2105
|
+
cancel: () => {
|
|
2106
|
+
killProc(proc);
|
|
2107
|
+
},
|
|
2108
|
+
done: outcomeWhenSettled(proc)
|
|
2109
|
+
})
|
|
2110
|
+
});
|
|
2111
|
+
} catch (error) {
|
|
2112
|
+
killProc(proc);
|
|
2113
|
+
throw error;
|
|
2114
|
+
}
|
|
2115
|
+
enforceOcrDeadline(registry, jobId, messages.jobReasonTimeout, job.backgroundMaxMs);
|
|
2116
|
+
const stop = () => {
|
|
2117
|
+
try {
|
|
2118
|
+
registry.kill(jobId, void 0, messages.jobReasonAborted);
|
|
2119
|
+
} catch {
|
|
2120
|
+
killProc(proc);
|
|
2121
|
+
}
|
|
2122
|
+
};
|
|
2123
|
+
const detach = () => {
|
|
2124
|
+
signal.removeEventListener("abort", stop);
|
|
2125
|
+
};
|
|
2126
|
+
if (signal.aborted) stop();
|
|
2127
|
+
else {
|
|
2128
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
2129
|
+
detachWhenSettled(proc, detach);
|
|
2130
|
+
}
|
|
2131
|
+
return {
|
|
2132
|
+
started: true,
|
|
2133
|
+
mode: "background",
|
|
2134
|
+
tool: job.tool,
|
|
2135
|
+
repo: job.repo,
|
|
2136
|
+
processStatus: proc.status,
|
|
2137
|
+
polling: messages.backgroundPolling
|
|
2138
|
+
};
|
|
2139
|
+
}
|
|
2140
|
+
/** 只收集不抛错(llm test 等需要展示原始失败的命令)。输出必先脱敏(直通卡片)。 */
|
|
2141
|
+
async function runCollect(host, command, workdir, run) {
|
|
2142
|
+
const spec = host.shell.resolve({
|
|
2143
|
+
command,
|
|
2144
|
+
workdir,
|
|
2145
|
+
timeoutMs: run.timeoutMs,
|
|
2146
|
+
stdoutMaxBytes: run.stdoutMaxBytes
|
|
2147
|
+
});
|
|
2148
|
+
const result = await (await host.shell.execute(spec)).result();
|
|
2149
|
+
const joined = result.stdout.text === "" || result.stderr.text === "" ? result.stdout.text + result.stderr.text : `${result.stdout.text}\n${result.stderr.text}`;
|
|
2150
|
+
return {
|
|
2151
|
+
exitCode: result.exitCode,
|
|
2152
|
+
output: truncateEnd(redactKeyMaterial(joined), 8e3)
|
|
2153
|
+
};
|
|
2154
|
+
}
|
|
2155
|
+
function readEffort(config) {
|
|
2156
|
+
const raw = config.effort.get();
|
|
2157
|
+
return raw === "low" || raw === "high" ? raw : "medium";
|
|
2158
|
+
}
|
|
2159
|
+
function readLanguage(config) {
|
|
2160
|
+
const lang = config.language.get();
|
|
2161
|
+
return typeof lang === "string" && (lang === "中文" || lang === "English") ? lang : void 0;
|
|
2162
|
+
}
|
|
2163
|
+
function readAutoVerify(config) {
|
|
2164
|
+
return config.autoVerify.get() !== false;
|
|
2165
|
+
}
|
|
2166
|
+
function readMaxComments(config) {
|
|
2167
|
+
const raw = config.maxComments.get();
|
|
2168
|
+
const num = typeof raw === "number" ? raw : Number(raw);
|
|
2169
|
+
if (Number.isSafeInteger(num) && num >= 0) return num;
|
|
2170
|
+
return MAX_COMMENTS_DEFAULT;
|
|
2171
|
+
}
|
|
2172
|
+
/**
|
|
2173
|
+
* 外层墙钟超时(分钟)→ 实际请求毫秒。0/未设置 = 请求宿主上限(宿主按
|
|
2174
|
+
* min(请求, shell.maxTimeoutMs) 收口,见 MAX_TIMEOUT_REQUEST_MS 注释);
|
|
2175
|
+
* >0 = 指定分钟(同样被宿主 clamp)。非法值按 0 处理。
|
|
2176
|
+
*/
|
|
2177
|
+
function readTimeoutMinutes(config) {
|
|
2178
|
+
const raw = config.timeoutMinutes.get();
|
|
2179
|
+
const num = typeof raw === "number" ? raw : Number(raw);
|
|
2180
|
+
if (Number.isSafeInteger(num) && num > 0) return num * 6e4;
|
|
2181
|
+
return MAX_TIMEOUT_REQUEST_MS;
|
|
2182
|
+
}
|
|
2183
|
+
/** 一次前台执行的两枚部署值:墙钟每次现读(`timeoutMinutes` 是 volatile 设置项),
|
|
2184
|
+
* stdout 上限取 entry config(非 volatile,装载期定值)。 */
|
|
2185
|
+
function tuningOf(config) {
|
|
2186
|
+
return {
|
|
2187
|
+
timeoutMs: readTimeoutMinutes(config),
|
|
2188
|
+
stdoutMaxBytes: config.stdoutMaxBytes
|
|
2189
|
+
};
|
|
2190
|
+
}
|
|
2191
|
+
/** parameters.required 的读取(本插件自己写的 schema,但门缺一即放行 = 副作用
|
|
2192
|
+
* 照跑,故不合形态就抛错,绝不静默当「无必填」)。 */
|
|
2193
|
+
function requiredKeys(parameters, messages) {
|
|
2194
|
+
const raw = parameters["required"];
|
|
2195
|
+
if (raw === void 0) return [];
|
|
2196
|
+
if (!Array.isArray(raw)) throw new TypeError(messages.requiredNotArray);
|
|
2197
|
+
const out = [];
|
|
2198
|
+
for (const key of raw) {
|
|
2199
|
+
if (typeof key !== "string") throw new TypeError(messages.requiredItemNotString);
|
|
2200
|
+
out.push(key);
|
|
2201
|
+
}
|
|
2202
|
+
return out;
|
|
2203
|
+
}
|
|
2204
|
+
/** 宿主对工具入参零校验(execute 收原始 arguments)→ 非对象入参必须当场拒绝,
|
|
2205
|
+
* 否则 spread / 必填门都会按「缺省」语义静默跑一次真实评审。 */
|
|
2206
|
+
function execArgs(args, name, messages) {
|
|
2207
|
+
if (!isRecord(args)) throw new TypeError(format(messages.argsMustBeObject, {
|
|
2208
|
+
name,
|
|
2209
|
+
received: Array.isArray(args) ? "array" : typeof args
|
|
2210
|
+
}));
|
|
2211
|
+
return args;
|
|
2212
|
+
}
|
|
2213
|
+
function define(host, spec) {
|
|
2214
|
+
const { name, description, parameters, schema, run } = spec;
|
|
2215
|
+
const required = requiredKeys(parameters, localeMessages(host));
|
|
2216
|
+
host.tools.register({
|
|
2217
|
+
name,
|
|
2218
|
+
description,
|
|
2219
|
+
parameters,
|
|
2220
|
+
output: {
|
|
2221
|
+
schema,
|
|
2222
|
+
render: (_args, value) => textBlock(value)
|
|
2223
|
+
},
|
|
2224
|
+
execute: async (args, exec) => {
|
|
2225
|
+
const messages = localeMessages(host);
|
|
2226
|
+
const values = execArgs(args, name, messages);
|
|
2227
|
+
const missing = required.filter((key) => values[key] === void 0);
|
|
2228
|
+
if (missing.length > 0) throw new Error(format(messages.missingRequiredArgs, {
|
|
2229
|
+
name,
|
|
2230
|
+
keys: missing.join(", ")
|
|
2231
|
+
}));
|
|
2232
|
+
return assertToolOutput(schema, await run(values, exec), name, messages);
|
|
2233
|
+
}
|
|
2234
|
+
});
|
|
2235
|
+
}
|
|
2236
|
+
/** body 文本 → JSON 对象(空 body = {};非对象抛错)。 */
|
|
2237
|
+
function parseJsonBody(text, messages) {
|
|
2238
|
+
if (text.trim() === "") return {};
|
|
2239
|
+
const parsed = JSON.parse(text);
|
|
2240
|
+
if (!isRecord(parsed)) throw new Error(messages.bodyMustBeObject);
|
|
2241
|
+
return parsed;
|
|
2242
|
+
}
|
|
2243
|
+
/**
|
|
2244
|
+
* POST 端点统一前置:405 → 跨域 → CSRF → body 上限(shared.guardBody)。
|
|
2245
|
+
*
|
|
2246
|
+
* 此前只有 sec-fetch-site(curl/本地进程可伪造头,且空头直通),而写端点会改
|
|
2247
|
+
* ~/.opencodereview/config.json;/migrate 与 /test 甚至从不读 body——keep-alive
|
|
2248
|
+
* 连接上未读尽的请求体会把残余字节留给后续响应,且超限请求可以一直占内存。
|
|
2249
|
+
* 返回 null 表示已就地响应,调用方必须立即返回。
|
|
2250
|
+
*/
|
|
2251
|
+
async function guardPost(req, res, csrf) {
|
|
2252
|
+
if (req.method !== "POST") {
|
|
2253
|
+
res.setHeader("Allow", "POST");
|
|
2254
|
+
sendJson(res, 405, {
|
|
2255
|
+
ok: false,
|
|
2256
|
+
error: "POST only"
|
|
2257
|
+
});
|
|
2258
|
+
return null;
|
|
2259
|
+
}
|
|
2260
|
+
return await guardBody(req, res, {
|
|
2261
|
+
maxBytes: BODY_MAX_BYTES,
|
|
2262
|
+
csrf: {
|
|
2263
|
+
token: csrf,
|
|
2264
|
+
headerName: OCR_CSRF_HEADER
|
|
2265
|
+
}
|
|
2266
|
+
});
|
|
2267
|
+
}
|
|
2268
|
+
/** 原始输出回显给模型/卡片前的两道收口:截断 + 明文 key 脱敏。 */
|
|
2269
|
+
function echoRaw(text, limit) {
|
|
2270
|
+
return redactKeyMaterial(truncateEnd(text, limit));
|
|
2271
|
+
}
|
|
2272
|
+
/** 读 --output 落盘结果。OCR exit 0 却没写出文件(版本漂移/被中途 kill)时给
|
|
2273
|
+
* 可行动的错误,而不是把裸 ENOENT 栈丢给模型。 */
|
|
2274
|
+
function readOutFile(outPath, messages) {
|
|
2275
|
+
try {
|
|
2276
|
+
return readFileSync(outPath, "utf8");
|
|
2277
|
+
} catch (error) {
|
|
2278
|
+
throw new Error(format(messages.outputFileMissing, {
|
|
2279
|
+
path: outPath,
|
|
2280
|
+
cause: errorText(error)
|
|
2281
|
+
}), { cause: error });
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
/** 运行 ocr llm test 校验当前配置(select 后自动验证 / 手动测试共用)。
|
|
2285
|
+
* 墙钟与输出上限都是 entry config 字段(非 volatile,改值随重启生效)。 */
|
|
2286
|
+
async function runLlmTest(host, config) {
|
|
2287
|
+
const { exitCode, output } = await runCollect(host, "ocr llm test --color never", process.env["HOME"] ?? "/", {
|
|
2288
|
+
timeoutMs: config.llmTestTimeoutMs,
|
|
2289
|
+
stdoutMaxBytes: config.stdoutMaxBytes
|
|
2290
|
+
});
|
|
2291
|
+
return {
|
|
2292
|
+
ok: exitCode === 0,
|
|
2293
|
+
output
|
|
2294
|
+
};
|
|
2295
|
+
}
|
|
2296
|
+
/**
|
|
2297
|
+
* OCR 原生数值参数的 tool schema 片段(忠实透传语义,桥接层不设钳制)。
|
|
2298
|
+
* unit 区分 review(每组)/ scan(每文件)的措辞(取消息表的 unitGroup/unitFile,
|
|
2299
|
+
* 语言随设置页偏好走);OCR 侧语义见 shared_flags.go:52-58 与 scan_cmd.go:231-236。
|
|
2300
|
+
*/
|
|
2301
|
+
function tuningParams(messages, unit) {
|
|
2302
|
+
const of = (template) => ({
|
|
2303
|
+
type: "integer",
|
|
2304
|
+
description: format(template, { unit })
|
|
2305
|
+
});
|
|
2306
|
+
return {
|
|
2307
|
+
concurrency: of(messages.tuningConcurrency),
|
|
2308
|
+
timeoutMinutes: of(messages.tuningTimeout),
|
|
2309
|
+
maxTools: of(messages.tuningMaxTools),
|
|
2310
|
+
maxTokens: of(messages.tuningMaxTokens),
|
|
2311
|
+
maxTokensBudget: {
|
|
2312
|
+
type: "integer",
|
|
2313
|
+
description: messages.tuningMaxTokensBudget
|
|
2314
|
+
}
|
|
2315
|
+
};
|
|
2316
|
+
}
|
|
2317
|
+
/** settings 命名空间与 loader 行 config 共用同一 schema(单源,防漂移)。
|
|
2318
|
+
* 值名退避为 configSchema:避免与同名 interface Config 触发 no-redeclare;
|
|
2319
|
+
* 外部仍以 `Config` 名导入(export as),公开 API 不变。 */
|
|
2320
|
+
const configSchema = Schema.object({
|
|
2321
|
+
effort: Schema.union([
|
|
2322
|
+
"low",
|
|
2323
|
+
"medium",
|
|
2324
|
+
"high"
|
|
2325
|
+
]).default("medium").volatile(),
|
|
2326
|
+
language: Schema.union(["中文", "English"]).default("中文").volatile(),
|
|
2327
|
+
autoVerify: Schema.boolean().default(true).volatile(),
|
|
2328
|
+
maxComments: Schema.natural().default(MAX_COMMENTS_DEFAULT).volatile(),
|
|
2329
|
+
timeoutMinutes: Schema.natural().default(0).volatile(),
|
|
2330
|
+
ocrConfigPath: Schema.string().volatile(),
|
|
2331
|
+
llmTestTimeoutMs: Schema.natural().min(1).default(LLM_TEST_TIMEOUT_MS),
|
|
2332
|
+
ocrBackgroundMaxMs: Schema.natural().min(1).default(OCR_BACKGROUND_MAX_MS),
|
|
2333
|
+
stdoutMaxBytes: Schema.natural().min(1).default(STDOUT_MAX_BYTES)
|
|
2334
|
+
});
|
|
2335
|
+
/** 设置项里的 ocrConfigPath:非字符串或空白一律视为「未覆盖」。 */
|
|
2336
|
+
function readOcrConfigPath(config) {
|
|
2337
|
+
const raw = config.ocrConfigPath.get();
|
|
2338
|
+
return typeof raw === "string" && raw.trim() !== "" ? raw.trim() : void 0;
|
|
2339
|
+
}
|
|
2340
|
+
/** 本包剩下的文件路径:只剩外部 ocr CLI 的 config.json(可被设置项覆盖)+ 脚本位置。
|
|
2341
|
+
* messages 只用于「设置项里的路径不合形态」那条可读错误。 */
|
|
2342
|
+
function pluginPaths(config, messages) {
|
|
2343
|
+
return {
|
|
2344
|
+
ocrConfigJson: resolveOcrConfigPath(readOcrConfigPath(config), messages),
|
|
2345
|
+
pluginDir: PLUGIN_DIR,
|
|
2346
|
+
getCredScript: defaultCredScriptPath(PLUGIN_DIR)
|
|
2347
|
+
};
|
|
2348
|
+
}
|
|
2349
|
+
/** 服务界面齐备才算可用(get() 可能返回半装配对象,也可能返回 undefined)。 */
|
|
2350
|
+
function usableService(value, methods) {
|
|
2351
|
+
return value !== void 0 && hasMethods(value, methods) ? value : void 0;
|
|
2352
|
+
}
|
|
2353
|
+
/**
|
|
2354
|
+
* 装配 dsh 官方配置/凭据通道(lib/provider-source.ts 只认这个门面)。
|
|
2355
|
+
*
|
|
2356
|
+
* 探测放 host 侧:只有这里知道 ctx 上到底装没装那两个服务;lib 拿到的是 health 布尔
|
|
2357
|
+
* + 已收窄的服务,据此产出卡片可读的降级原因。所有分支都不许抛穿到会话热路径。
|
|
2358
|
+
* credentials 的 describe/resolve 只回「是否已配置 / 值能否解析」,resolve 拿到的
|
|
2359
|
+
* 值当场丢弃——明文 key 绝不出宿主(更不进 HTTP)。
|
|
2360
|
+
*/
|
|
2361
|
+
function createGateway(host) {
|
|
2362
|
+
const settingsService = () => usableService(host.get("settings"), ["describe"]);
|
|
2363
|
+
const credentialsService = () => usableService(host.get("credentials"), ["describe", "resolve"]);
|
|
2364
|
+
return {
|
|
2365
|
+
health: () => ({
|
|
2366
|
+
settings: settingsService() !== void 0,
|
|
2367
|
+
credentials: credentialsService() !== void 0
|
|
2368
|
+
}),
|
|
2369
|
+
descriptors: () => {
|
|
2370
|
+
const svc = settingsService();
|
|
2371
|
+
if (svc === void 0) throw new Error(format(localeMessages(host).settingsServiceGone, { ns: PI_AI_NAMESPACE }));
|
|
2372
|
+
return svc.describe();
|
|
2373
|
+
},
|
|
2374
|
+
credentialConfigured: async (envKey) => {
|
|
2375
|
+
const svc = credentialsService();
|
|
2376
|
+
if (svc === void 0 || !isCredentialRefName(envKey)) return false;
|
|
2377
|
+
try {
|
|
2378
|
+
return (await svc.describe(credentialRef(envKey))).configured;
|
|
2379
|
+
} catch {
|
|
2380
|
+
return false;
|
|
2381
|
+
}
|
|
2382
|
+
},
|
|
2383
|
+
credentialResolvable: async (envKey) => {
|
|
2384
|
+
const svc = credentialsService();
|
|
2385
|
+
if (svc === void 0 || !isCredentialRefName(envKey)) return false;
|
|
2386
|
+
try {
|
|
2387
|
+
const resolved = await svc.resolve(credentialRef(envKey));
|
|
2388
|
+
return resolved !== void 0 && resolved.value.length > 0;
|
|
2389
|
+
} catch {
|
|
2390
|
+
return false;
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
};
|
|
2394
|
+
}
|
|
2395
|
+
/** repo 解析:显式绝对路径优先,缺省回退当前会话工作区(导出仅供单测,
|
|
2396
|
+
* 同 plugin-hot-reload 把内部纯函数导出的惯例)。 */
|
|
2397
|
+
function ocrRepo(args, exec, messages) {
|
|
2398
|
+
return resolveRoot(args["repo"], sessionHeaderCwd(exec), messages);
|
|
2399
|
+
}
|
|
2400
|
+
/** POST select 的响应:把 body 里的 provider/model 应用到 OCR 配置,按需附带 llm test 回执。 */
|
|
2401
|
+
async function sendSelectResponse(text, res, deps) {
|
|
2402
|
+
const msgs = deps.messages();
|
|
2403
|
+
let body;
|
|
2404
|
+
try {
|
|
2405
|
+
body = parseJsonBody(text, msgs);
|
|
2406
|
+
} catch (error) {
|
|
2407
|
+
sendJson(res, 400, {
|
|
2408
|
+
ok: false,
|
|
2409
|
+
error: format(msgs.bodyParseFailed, { message: errorText(error) })
|
|
2410
|
+
});
|
|
2411
|
+
return;
|
|
2412
|
+
}
|
|
2413
|
+
const provider = typeof body["provider"] === "string" ? body["provider"] : "";
|
|
2414
|
+
const model = typeof body["model"] === "string" ? body["model"] : "";
|
|
2415
|
+
if (provider === "" || model === "") {
|
|
2416
|
+
sendJson(res, 400, {
|
|
2417
|
+
ok: false,
|
|
2418
|
+
error: msgs.providerModelRequired
|
|
2419
|
+
});
|
|
2420
|
+
return;
|
|
2421
|
+
}
|
|
2422
|
+
try {
|
|
2423
|
+
const language = readLanguage(deps.config);
|
|
2424
|
+
const applied = await applyProviderSelection(deps.gateway, pluginPaths(deps.config, msgs), {
|
|
2425
|
+
provider,
|
|
2426
|
+
model,
|
|
2427
|
+
...language === void 0 ? {} : { language }
|
|
2428
|
+
}, msgs);
|
|
2429
|
+
let llmTest = null;
|
|
2430
|
+
if (readAutoVerify(deps.config)) llmTest = await runLlmTest(deps.host, deps.config);
|
|
2431
|
+
sendJson(res, 200, {
|
|
2432
|
+
ok: true,
|
|
2433
|
+
applied,
|
|
2434
|
+
llmTest
|
|
2435
|
+
});
|
|
2436
|
+
} catch (error) {
|
|
2437
|
+
sendJson(res, 400, {
|
|
2438
|
+
ok: false,
|
|
2439
|
+
error: errorText(error)
|
|
2440
|
+
});
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
/** 注册 webServer 配置端点(卡片专用:providers/select/migrate/test)。
|
|
2444
|
+
* csrf 为每次 apply 生成的会话内 token:GET providers 下发、三个写端点回填
|
|
2445
|
+
* 校验(sec-fetch-site 只挡浏览器,挡不住能构造请求头的本地进程)。
|
|
2446
|
+
* ctx 只用来挂释放器,给的是 `inject(["webServer"])` 子 fiber 的上下文。 */
|
|
2447
|
+
function registerConfigEndpoints(ctx, host, config, csrf) {
|
|
2448
|
+
const webServer = host.get("webServer");
|
|
2449
|
+
if (!webServer) return;
|
|
2450
|
+
const servingNonLoopback = webServer.host === "0.0.0.0";
|
|
2451
|
+
const gateway = createGateway(host);
|
|
2452
|
+
const messages = () => localeMessages(host);
|
|
2453
|
+
const disposeProviders = webServer.register({
|
|
2454
|
+
kind: "exact",
|
|
2455
|
+
path: PROVIDERS_PATH,
|
|
2456
|
+
handler: async (req, res) => {
|
|
2457
|
+
if (!guardTrust(req, res, { servingNonLoopback })) return;
|
|
2458
|
+
try {
|
|
2459
|
+
const msgs = messages();
|
|
2460
|
+
const payload = await providersForCard(gateway, pluginPaths(config, msgs), msgs);
|
|
2461
|
+
sendJson(res, 200, {
|
|
2462
|
+
...payload,
|
|
2463
|
+
csrf
|
|
2464
|
+
});
|
|
2465
|
+
} catch (error) {
|
|
2466
|
+
sendJson(res, 500, {
|
|
2467
|
+
ok: false,
|
|
2468
|
+
error: errorText(error)
|
|
2469
|
+
});
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
});
|
|
2473
|
+
const disposeSelect = webServer.register({
|
|
2474
|
+
kind: "exact",
|
|
2475
|
+
path: SELECT_PATH,
|
|
2476
|
+
handler: async (req, res) => {
|
|
2477
|
+
if (!guardTrust(req, res, { servingNonLoopback })) return;
|
|
2478
|
+
const text = await guardPost(req, res, csrf);
|
|
2479
|
+
if (text === null) return;
|
|
2480
|
+
await sendSelectResponse(text, res, {
|
|
2481
|
+
config,
|
|
2482
|
+
gateway,
|
|
2483
|
+
host,
|
|
2484
|
+
messages
|
|
2485
|
+
});
|
|
2486
|
+
}
|
|
2487
|
+
});
|
|
2488
|
+
const disposeMigrate = webServer.register({
|
|
2489
|
+
kind: "exact",
|
|
2490
|
+
path: MIGRATE_PATH,
|
|
2491
|
+
handler: async (req, res) => {
|
|
2492
|
+
if (!guardTrust(req, res, { servingNonLoopback })) return;
|
|
2493
|
+
if (await guardPost(req, res, csrf) === null) return;
|
|
2494
|
+
try {
|
|
2495
|
+
const msgs = messages();
|
|
2496
|
+
const result = await migratePlaintextKeys(gateway, pluginPaths(config, msgs), msgs);
|
|
2497
|
+
sendJson(res, 200, {
|
|
2498
|
+
ok: true,
|
|
2499
|
+
...result
|
|
2500
|
+
});
|
|
2501
|
+
} catch (error) {
|
|
2502
|
+
sendJson(res, 400, {
|
|
2503
|
+
ok: false,
|
|
2504
|
+
error: errorText(error)
|
|
2505
|
+
});
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
});
|
|
2509
|
+
const disposeTest = webServer.register({
|
|
2510
|
+
kind: "exact",
|
|
2511
|
+
path: TEST_PATH,
|
|
2512
|
+
handler: async (req, res) => {
|
|
2513
|
+
if (!guardTrust(req, res, { servingNonLoopback })) return;
|
|
2514
|
+
if (await guardPost(req, res, csrf) === null) return;
|
|
2515
|
+
try {
|
|
2516
|
+
const llmTest = await runLlmTest(host, config);
|
|
2517
|
+
sendJson(res, 200, {
|
|
2518
|
+
ok: llmTest.ok,
|
|
2519
|
+
output: llmTest.output
|
|
2520
|
+
});
|
|
2521
|
+
} catch (error) {
|
|
2522
|
+
sendJson(res, 400, {
|
|
2523
|
+
ok: false,
|
|
2524
|
+
error: errorText(error)
|
|
2525
|
+
});
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
});
|
|
2529
|
+
ctx.effect(() => () => {
|
|
2530
|
+
disposeProviders();
|
|
2531
|
+
disposeSelect();
|
|
2532
|
+
disposeMigrate();
|
|
2533
|
+
disposeTest();
|
|
2534
|
+
}, "ocr-review: config endpoints");
|
|
2535
|
+
}
|
|
2536
|
+
function registerReviewTool(host, config) {
|
|
2537
|
+
const msgs = localeMessages(host);
|
|
2538
|
+
define(host, {
|
|
2539
|
+
name: "ocr_review",
|
|
2540
|
+
description: msgs.reviewToolDescription,
|
|
2541
|
+
parameters: {
|
|
2542
|
+
type: "object",
|
|
2543
|
+
additionalProperties: false,
|
|
2544
|
+
properties: {
|
|
2545
|
+
repo: {
|
|
2546
|
+
type: "string",
|
|
2547
|
+
description: msgs.paramRepo
|
|
2548
|
+
},
|
|
2549
|
+
scope: {
|
|
2550
|
+
type: "string",
|
|
2551
|
+
enum: [
|
|
2552
|
+
"workspace",
|
|
2553
|
+
"commit",
|
|
2554
|
+
"branch"
|
|
2555
|
+
],
|
|
2556
|
+
description: msgs.paramScope
|
|
2557
|
+
},
|
|
2558
|
+
commit: {
|
|
2559
|
+
type: "string",
|
|
2560
|
+
description: msgs.paramCommit
|
|
2561
|
+
},
|
|
2562
|
+
from: {
|
|
2563
|
+
type: "string",
|
|
2564
|
+
description: msgs.paramFrom
|
|
2565
|
+
},
|
|
2566
|
+
to: {
|
|
2567
|
+
type: "string",
|
|
2568
|
+
description: msgs.paramTo
|
|
2569
|
+
},
|
|
2570
|
+
effort: {
|
|
2571
|
+
type: "string",
|
|
2572
|
+
enum: [
|
|
2573
|
+
"low",
|
|
2574
|
+
"medium",
|
|
2575
|
+
"high"
|
|
2576
|
+
],
|
|
2577
|
+
description: msgs.paramEffort
|
|
2578
|
+
},
|
|
2579
|
+
background: {
|
|
2580
|
+
type: "string",
|
|
2581
|
+
description: msgs.paramBackground
|
|
2582
|
+
},
|
|
2583
|
+
exclude: {
|
|
2584
|
+
type: "array",
|
|
2585
|
+
items: { type: "string" },
|
|
2586
|
+
description: msgs.paramExclude
|
|
2587
|
+
},
|
|
2588
|
+
...tuningParams(msgs, msgs.unitGroup),
|
|
2589
|
+
wait: {
|
|
2590
|
+
type: "boolean",
|
|
2591
|
+
description: msgs.paramWait
|
|
2592
|
+
},
|
|
2593
|
+
provider: {
|
|
2594
|
+
type: "string",
|
|
2595
|
+
description: msgs.paramProvider
|
|
2596
|
+
},
|
|
2597
|
+
model: {
|
|
2598
|
+
type: "string",
|
|
2599
|
+
description: msgs.paramModel
|
|
2600
|
+
},
|
|
2601
|
+
resume: {
|
|
2602
|
+
type: "string",
|
|
2603
|
+
description: msgs.paramResume
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
},
|
|
2607
|
+
schema: REVIEW_OUTPUT_SCHEMA,
|
|
2608
|
+
run: async (args, exec) => {
|
|
2609
|
+
const runMsgs = localeMessages(host);
|
|
2610
|
+
const repo = ocrRepo(args, exec, runMsgs);
|
|
2611
|
+
const eff = args["effort"] === void 0 ? readEffort(config) : args["effort"];
|
|
2612
|
+
const tmp = args["wait"] !== false ? mkdtempSync(path.join(tmpdir(), "ocr-review-")) : void 0;
|
|
2613
|
+
const outPath = tmp === void 0 ? void 0 : path.join(tmp, "out.json");
|
|
2614
|
+
try {
|
|
2615
|
+
const { command, workdir } = buildReviewCommand({
|
|
2616
|
+
...args,
|
|
2617
|
+
repo,
|
|
2618
|
+
effort: eff
|
|
2619
|
+
}, outPath, runMsgs);
|
|
2620
|
+
const guarded = wrapWithHostReaper(command);
|
|
2621
|
+
if (outPath === void 0) return await startBackground(host, {
|
|
2622
|
+
command: guarded,
|
|
2623
|
+
workdir,
|
|
2624
|
+
tool: "ocr_review",
|
|
2625
|
+
repo,
|
|
2626
|
+
stdoutMaxBytes: config.stdoutMaxBytes,
|
|
2627
|
+
backgroundMaxMs: config.ocrBackgroundMaxMs
|
|
2628
|
+
}, exec.signal);
|
|
2629
|
+
await runForeground(host, guarded, workdir, tuningOf(config), exec.signal);
|
|
2630
|
+
const text = readOutFile(outPath, runMsgs);
|
|
2631
|
+
const parsed = parseReviewOutput(text, runMsgs);
|
|
2632
|
+
if (!parsed.ok) return format(runMsgs.parseFailedWithReason, {
|
|
2633
|
+
tool: "ocr",
|
|
2634
|
+
message: parsed.message,
|
|
2635
|
+
limit: String(RAW_ECHO_LIMIT),
|
|
2636
|
+
raw: echoRaw(text, RAW_ECHO_LIMIT)
|
|
2637
|
+
});
|
|
2638
|
+
return summarizeReview(parsed, runMsgs, { maxComments: readMaxComments(config) });
|
|
2639
|
+
} finally {
|
|
2640
|
+
if (tmp !== void 0) rmSync(tmp, {
|
|
2641
|
+
recursive: true,
|
|
2642
|
+
force: true
|
|
2643
|
+
});
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
});
|
|
2647
|
+
}
|
|
2648
|
+
function registerScanTool(host, config) {
|
|
2649
|
+
const msgs = localeMessages(host);
|
|
2650
|
+
define(host, {
|
|
2651
|
+
name: "ocr_scan",
|
|
2652
|
+
description: msgs.scanToolDescription,
|
|
2653
|
+
parameters: {
|
|
2654
|
+
type: "object",
|
|
2655
|
+
additionalProperties: false,
|
|
2656
|
+
properties: {
|
|
2657
|
+
repo: {
|
|
2658
|
+
type: "string",
|
|
2659
|
+
description: msgs.paramRepo
|
|
2660
|
+
},
|
|
2661
|
+
path: {
|
|
2662
|
+
type: "array",
|
|
2663
|
+
items: { type: "string" },
|
|
2664
|
+
description: msgs.paramScanPath
|
|
2665
|
+
},
|
|
2666
|
+
background: {
|
|
2667
|
+
type: "string",
|
|
2668
|
+
description: msgs.paramBackgroundShort
|
|
2669
|
+
},
|
|
2670
|
+
exclude: {
|
|
2671
|
+
type: "array",
|
|
2672
|
+
items: { type: "string" },
|
|
2673
|
+
description: msgs.paramExclude
|
|
2674
|
+
},
|
|
2675
|
+
batch: {
|
|
2676
|
+
type: "string",
|
|
2677
|
+
enum: [
|
|
2678
|
+
"none",
|
|
2679
|
+
"by-language",
|
|
2680
|
+
"by-directory"
|
|
2681
|
+
],
|
|
2682
|
+
description: msgs.paramBatch
|
|
2683
|
+
},
|
|
2684
|
+
...tuningParams(msgs, msgs.unitFile),
|
|
2685
|
+
wait: {
|
|
2686
|
+
type: "boolean",
|
|
2687
|
+
description: msgs.paramWait
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
},
|
|
2691
|
+
schema: REVIEW_OUTPUT_SCHEMA,
|
|
2692
|
+
run: async (args, exec) => {
|
|
2693
|
+
const runMsgs = localeMessages(host);
|
|
2694
|
+
const repo = ocrRepo(args, exec, runMsgs);
|
|
2695
|
+
const tmp = args["wait"] !== false ? mkdtempSync(path.join(tmpdir(), "ocr-scan-")) : void 0;
|
|
2696
|
+
const outPath = tmp === void 0 ? void 0 : path.join(tmp, "out.json");
|
|
2697
|
+
try {
|
|
2698
|
+
const { command, workdir } = buildScanCommand({
|
|
2699
|
+
...args,
|
|
2700
|
+
repo
|
|
2701
|
+
}, outPath, runMsgs);
|
|
2702
|
+
const guarded = wrapWithHostReaper(command);
|
|
2703
|
+
if (outPath === void 0) return await startBackground(host, {
|
|
2704
|
+
command: guarded,
|
|
2705
|
+
workdir,
|
|
2706
|
+
tool: "ocr_scan",
|
|
2707
|
+
repo,
|
|
2708
|
+
stdoutMaxBytes: config.stdoutMaxBytes,
|
|
2709
|
+
backgroundMaxMs: config.ocrBackgroundMaxMs
|
|
2710
|
+
}, exec.signal);
|
|
2711
|
+
await runForeground(host, guarded, workdir, tuningOf(config), exec.signal);
|
|
2712
|
+
const text = readOutFile(outPath, runMsgs);
|
|
2713
|
+
const parsed = parseReviewOutput(text, runMsgs);
|
|
2714
|
+
if (!parsed.ok) return format(runMsgs.parseFailedWithReason, {
|
|
2715
|
+
tool: "ocr scan",
|
|
2716
|
+
message: parsed.message,
|
|
2717
|
+
limit: String(RAW_ECHO_LIMIT),
|
|
2718
|
+
raw: echoRaw(text, RAW_ECHO_LIMIT)
|
|
2719
|
+
});
|
|
2720
|
+
return summarizeReview(parsed, runMsgs, { maxComments: readMaxComments(config) });
|
|
2721
|
+
} finally {
|
|
2722
|
+
if (tmp !== void 0) rmSync(tmp, {
|
|
2723
|
+
recursive: true,
|
|
2724
|
+
force: true
|
|
2725
|
+
});
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
});
|
|
2729
|
+
}
|
|
2730
|
+
function registerDelegatePreviewTool(host, config) {
|
|
2731
|
+
const msgs = localeMessages(host);
|
|
2732
|
+
define(host, {
|
|
2733
|
+
name: "ocr_delegate_preview",
|
|
2734
|
+
description: msgs.previewToolDescription,
|
|
2735
|
+
parameters: {
|
|
2736
|
+
type: "object",
|
|
2737
|
+
additionalProperties: false,
|
|
2738
|
+
properties: {
|
|
2739
|
+
repo: {
|
|
2740
|
+
type: "string",
|
|
2741
|
+
description: msgs.paramRepo
|
|
2742
|
+
},
|
|
2743
|
+
scope: {
|
|
2744
|
+
type: "string",
|
|
2745
|
+
enum: [
|
|
2746
|
+
"workspace",
|
|
2747
|
+
"commit",
|
|
2748
|
+
"branch"
|
|
2749
|
+
],
|
|
2750
|
+
description: msgs.paramScope
|
|
2751
|
+
},
|
|
2752
|
+
commit: {
|
|
2753
|
+
type: "string",
|
|
2754
|
+
description: msgs.paramCommitShort
|
|
2755
|
+
},
|
|
2756
|
+
from: {
|
|
2757
|
+
type: "string",
|
|
2758
|
+
description: msgs.paramFromShort
|
|
2759
|
+
},
|
|
2760
|
+
to: {
|
|
2761
|
+
type: "string",
|
|
2762
|
+
description: msgs.paramToShort
|
|
2763
|
+
},
|
|
2764
|
+
background: {
|
|
2765
|
+
type: "string",
|
|
2766
|
+
description: msgs.paramBackgroundPreview
|
|
2767
|
+
},
|
|
2768
|
+
exclude: {
|
|
2769
|
+
type: "array",
|
|
2770
|
+
items: { type: "string" },
|
|
2771
|
+
description: msgs.paramExcludeShort
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
},
|
|
2775
|
+
schema: PREVIEW_OUTPUT_SCHEMA,
|
|
2776
|
+
run: async (args, exec) => {
|
|
2777
|
+
const runMsgs = localeMessages(host);
|
|
2778
|
+
const repo = ocrRepo(args, exec, runMsgs);
|
|
2779
|
+
const { command, workdir } = buildDelegatePreviewCommand({
|
|
2780
|
+
...args,
|
|
2781
|
+
repo
|
|
2782
|
+
}, runMsgs);
|
|
2783
|
+
const text = await runForeground(host, command, workdir, tuningOf(config), exec.signal);
|
|
2784
|
+
const parsed = parseDelegatePreview(text);
|
|
2785
|
+
if (!parsed.ok) return format(runMsgs.parseFailedPlain, {
|
|
2786
|
+
tool: "ocr delegate preview",
|
|
2787
|
+
limit: String(RAW_ECHO_LIMIT),
|
|
2788
|
+
raw: echoRaw(text, RAW_ECHO_LIMIT)
|
|
2789
|
+
});
|
|
2790
|
+
return {
|
|
2791
|
+
mode: parsed.mode,
|
|
2792
|
+
repository: parsed.repository,
|
|
2793
|
+
reviewableCount: parsed.reviewable.length,
|
|
2794
|
+
insertions: parsed.reviewable.reduce((acc, file) => acc + file.insertions, 0),
|
|
2795
|
+
deletions: parsed.reviewable.reduce((acc, file) => acc + file.deletions, 0),
|
|
2796
|
+
reviewableFiles: parsed.reviewable,
|
|
2797
|
+
excludedFiles: parsed.excluded,
|
|
2798
|
+
hint: runMsgs.previewHint
|
|
2799
|
+
};
|
|
2800
|
+
}
|
|
2801
|
+
});
|
|
2802
|
+
}
|
|
2803
|
+
function registerDelegateRuleTool(host, config) {
|
|
2804
|
+
const msgs = localeMessages(host);
|
|
2805
|
+
define(host, {
|
|
2806
|
+
name: "ocr_delegate_rule",
|
|
2807
|
+
description: msgs.ruleToolDescription,
|
|
2808
|
+
parameters: {
|
|
2809
|
+
type: "object",
|
|
2810
|
+
additionalProperties: false,
|
|
2811
|
+
properties: {
|
|
2812
|
+
repo: {
|
|
2813
|
+
type: "string",
|
|
2814
|
+
description: msgs.paramRepo
|
|
2815
|
+
},
|
|
2816
|
+
paths: {
|
|
2817
|
+
type: "array",
|
|
2818
|
+
items: { type: "string" },
|
|
2819
|
+
description: msgs.paramPaths
|
|
2820
|
+
}
|
|
2821
|
+
},
|
|
2822
|
+
required: ["paths"]
|
|
2823
|
+
},
|
|
2824
|
+
schema: RULES_OUTPUT_SCHEMA,
|
|
2825
|
+
run: async (args, exec) => {
|
|
2826
|
+
const runMsgs = localeMessages(host);
|
|
2827
|
+
const { command, workdir } = buildDelegateRuleCommand({
|
|
2828
|
+
repo: ocrRepo(args, exec, runMsgs),
|
|
2829
|
+
paths: args["paths"]
|
|
2830
|
+
}, runMsgs);
|
|
2831
|
+
const text = await runForeground(host, command, workdir, tuningOf(config), exec.signal);
|
|
2832
|
+
const parsed = parseDelegateRules(text);
|
|
2833
|
+
if (!parsed.ok) return format(runMsgs.parseFailedPlain, {
|
|
2834
|
+
tool: "ocr delegate rule",
|
|
2835
|
+
limit: String(RAW_ECHO_LIMIT),
|
|
2836
|
+
raw: echoRaw(text, RAW_ECHO_LIMIT)
|
|
2837
|
+
});
|
|
2838
|
+
return {
|
|
2839
|
+
groupCount: parsed.groups.length,
|
|
2840
|
+
groups: parsed.groups.map((group) => ({
|
|
2841
|
+
source: group.source,
|
|
2842
|
+
pattern: group.pattern,
|
|
2843
|
+
files: group.files,
|
|
2844
|
+
rule: group.rule
|
|
2845
|
+
})),
|
|
2846
|
+
hint: runMsgs.ruleHint
|
|
2847
|
+
};
|
|
2848
|
+
}
|
|
2849
|
+
});
|
|
2850
|
+
}
|
|
2851
|
+
function registerSessionTool(host, config) {
|
|
2852
|
+
const msgs = localeMessages(host);
|
|
2853
|
+
define(host, {
|
|
2854
|
+
name: "ocr_session",
|
|
2855
|
+
description: msgs.sessionToolDescription,
|
|
2856
|
+
parameters: {
|
|
2857
|
+
type: "object",
|
|
2858
|
+
additionalProperties: false,
|
|
2859
|
+
properties: {
|
|
2860
|
+
repo: {
|
|
2861
|
+
type: "string",
|
|
2862
|
+
description: msgs.paramRepo
|
|
2863
|
+
},
|
|
2864
|
+
action: {
|
|
2865
|
+
type: "string",
|
|
2866
|
+
enum: [
|
|
2867
|
+
"list",
|
|
2868
|
+
"show",
|
|
2869
|
+
"comments"
|
|
2870
|
+
],
|
|
2871
|
+
description: msgs.paramSessionAction
|
|
2872
|
+
},
|
|
2873
|
+
id: {
|
|
2874
|
+
type: "string",
|
|
2875
|
+
description: msgs.paramSessionId
|
|
2876
|
+
},
|
|
2877
|
+
limit: {
|
|
2878
|
+
type: "integer",
|
|
2879
|
+
description: msgs.paramSessionLimit
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
},
|
|
2883
|
+
schema: SESSION_OUTPUT_SCHEMA,
|
|
2884
|
+
run: async (args, exec) => {
|
|
2885
|
+
const runMsgs = localeMessages(host);
|
|
2886
|
+
const repo = ocrRepo(args, exec, runMsgs);
|
|
2887
|
+
const { command, workdir } = buildSessionCommand({
|
|
2888
|
+
...args,
|
|
2889
|
+
repo
|
|
2890
|
+
}, runMsgs);
|
|
2891
|
+
const text = await runForeground(host, command, workdir, tuningOf(config), exec.signal);
|
|
2892
|
+
const stripped = text.trim();
|
|
2893
|
+
const empty = stripped === "null" || stripped === "[]";
|
|
2894
|
+
const capped = text.length > 12e3 ? `${echoRaw(text, 11900)}\n${runMsgs.sessionTruncated}` : redactKeyMaterial(text);
|
|
2895
|
+
return {
|
|
2896
|
+
sessionOutput: empty ? runMsgs.sessionEmpty : capped,
|
|
2897
|
+
hint: runMsgs.sessionResumeHint
|
|
2898
|
+
};
|
|
2899
|
+
}
|
|
2900
|
+
});
|
|
2901
|
+
}
|
|
2902
|
+
function apply(ctx, config) {
|
|
2903
|
+
const host = resolveHost(ctx);
|
|
2904
|
+
ctx.effect(() => () => {
|
|
2905
|
+
const jobs = ctx.get("jobs");
|
|
2906
|
+
if (jobs !== void 0) reclaimOcrJobs(jobs, localeMessages(ctx).jobReasonUnload);
|
|
2907
|
+
}, "ocr-review: background jobs");
|
|
2908
|
+
host.inject(["settings"], (child) => {
|
|
2909
|
+
child.effect(() => child.settings.configure({ auto: false }, host.fiber));
|
|
2910
|
+
});
|
|
2911
|
+
registerReviewTool(host, config);
|
|
2912
|
+
registerScanTool(host, config);
|
|
2913
|
+
registerDelegatePreviewTool(host, config);
|
|
2914
|
+
registerDelegateRuleTool(host, config);
|
|
2915
|
+
registerSessionTool(host, config);
|
|
2916
|
+
host.systemPrompt.section({
|
|
2917
|
+
name: ROUTING_NAME,
|
|
2918
|
+
order: ROUTING_ORDER,
|
|
2919
|
+
text: localeMessages(host).routingText
|
|
2920
|
+
});
|
|
2921
|
+
host.inject(["webServer"], (child) => {
|
|
2922
|
+
registerConfigEndpoints(child, host, config, randomUUID());
|
|
2923
|
+
});
|
|
2924
|
+
}
|
|
2925
|
+
var host_default = {
|
|
2926
|
+
inject: [
|
|
2927
|
+
"settings",
|
|
2928
|
+
"shell",
|
|
2929
|
+
"tools",
|
|
2930
|
+
"systemPrompt"
|
|
2931
|
+
],
|
|
2932
|
+
Config: configSchema,
|
|
2933
|
+
apply
|
|
2934
|
+
};
|
|
2935
|
+
//#endregion
|
|
2936
|
+
export { configSchema as Config, createGateway, host_default as default, ocrRepo, parseJsonBody, registerConfigEndpoints, requiredKeys };
|