@jayyuen66/dsh-ocr-review 0.1.8 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cordis.patch.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  # ocr-review:open-code-review 工作区审查插件(bundle)。
2
2
  # 双面包机制:本包含 Host 半(host.ts:7 个工具 ocr_review/ocr_scan/
3
- # ocr_delegate_preview/ocr_delegate_rule/ocr_session + 由 `Config` 上 `.volatile()`
3
+ # ocr_delegate_preview/ocr_delegate_rule/ocr_session/ocr_rules_check/ocr_llm + 由 `Config` 上 `.volatile()`
4
4
  # 隐式投影的设置面(命名空间就是下面这条的 id)+ 设置卡的 `ocrReview` Remote 读面)与
5
5
  # Client 半(src/client-entry.ts → client.js:设置卡片,从宿主设置服务的
6
6
  # llm-pi-ai.providers 选 provider/model 应用到
package/docs/DETAIL.md ADDED
@@ -0,0 +1,28 @@
1
+ # Open code review
2
+
3
+ Runs the external `ocr` command line over a change set, reports the comments it produces, and manages which model does the reviewing.
4
+
5
+ ## What the card changes
6
+
7
+ - **Model** — `ocrProvider` and `ocrModel` are not stored settings: they project the host's model namespace, and entries without a usable key are shown but disabled. `chatGptModel` reflects the external CLI's own login state, which this plugin reads but does not own.
8
+ - **Review parameters** — `effort` (`auto`/`low`/`medium`/`high`) is how many review passes run: deeper review costs more time. `language` chooses Chinese or English output. `autoVerify` (on) runs `ocr llm test` after you pick a provider and model, so a wrong choice fails at pick time rather than at first review. `maxComments` (default 12; 0 means all, including comments that cannot be anchored to a line). `timeoutMinutes` (0 requests the host's ceiling; a positive value is minutes, still clamped by the host). `ocrConfigPath` — empty means `~/.opencodereview/config.json`; a non-empty value must be `<dir>/.opencodereview/config.json` and is honoured by redirecting `HOME` (POSIX only).
9
+
10
+ Six of these are read per call, so a change applies on the next review without restarting the host.
11
+
12
+ ## Two separate things called MCP
13
+
14
+ The card's MCP-servers editor writes the **external `ocr` tool's own** configuration. It is unrelated to the `integrationMode` / `mcpPort` surface of the Scrapling plugin, and nothing in this plugin serves MCP itself.
15
+
16
+ ## Restart-only values
17
+
18
+ `llmTestTimeoutMs` (90000), `startupVerify` (on: the endpoint self-check runs at load), `ocrBackgroundMaxMs` (0 = no backstop window), `stdoutMaxBytes` (400000), `ocrNoAutoUpdate` (false: upstream auto-upgrade stays on unless you say otherwise) and `ocrInstallHint` (`both`/`brew`/`npm`). Set them in the `config:` block of this plugin's row in `cordis.patch.yml`.
19
+
20
+ ## What it needs from outside
21
+
22
+ The `ocr` command line, expected at version **1.13.3**; the expectation is declared in code and mirrored in the package's manifest and READMEs, so a mismatch is reported rather than papered over. Install paths are `brew install open-code-review` or `npm i -g @alibaba-group/open-code-review@1.13.3`. A missing binary is reported as "command not found"; the case where a launcher exists but the binary does not says exactly that. The version probe always forces `OCR_NO_UPDATE=1` so probing cannot trigger an upgrade.
23
+
24
+ API credentials are not stored here: the plugin writes a `api_key_cmd` that calls a helper script, so the key is fetched at run time instead of landing in a config file. If that script is missing, the card warns rather than sending an unauthenticated request.
25
+
26
+ ## Further detail
27
+
28
+ The tool list exposed to the model, how background reviews become host jobs and the argument guard's flag table are in the package README (`README.md` / `README.zh-CN.md`).
@@ -0,0 +1,28 @@
1
+ # 代码评审(open-code-review)
2
+
3
+ 调用外部 `ocr` 命令行评审一个变更集,回报它给出的意见,并管理用来评审的模型。
4
+
5
+ ## 设置卡上改的是什么
6
+
7
+ - **模型** — `ocrProvider` 与 `ocrModel` 不是本插件存的值:它们是宿主模型命名空间的投影,没有可用密钥的条目照样列出但禁用。`chatGptModel` 反映的是外部命令行自己的登录态,本插件读它但不拥有它。
8
+ - **审查参数** — `effort`(`auto`/`low`/`medium`/`high`)是跑几轮审查,越深越费时。`language` 选中文或英文输出。`autoVerify`(默认开)在选完提供方与模型后跑一次 `ocr llm test`,选错当场就知道,而不是等第一次评审失败。`maxComments`(默认 12;0 表示全部,包括钉不到行上的意见)。`timeoutMinutes`(0 表示向宿主要上限;正数是分钟数,仍由宿主收口)。`ocrConfigPath`——留空就是 `~/.opencodereview/config.json`;填了就必须是 `<目录>/.opencodereview/config.json` 的形状,生效方式是重定向 `HOME`(仅 POSIX)。
9
+
10
+ 其中六项每次调用现读,改完下一次评审生效,不需要重启宿主。
11
+
12
+ ## 两处都叫 MCP,但不是同一件事
13
+
14
+ 卡上的 MCP servers 编辑器改的是**外部 `ocr` 工具自己的**配置。它与「Scrapling 网页抓取」那套 `integrationMode` / `mcpPort` 的 MCP 面没有关系,本插件也不对外提供 MCP 服务。
15
+
16
+ ## 要重启的值
17
+
18
+ `llmTestTimeoutMs`(90000)、`startupVerify`(默认开:装载时自检端点)、`ocrBackgroundMaxMs`(0 表示不设兜底窗口)、`stdoutMaxBytes`(400000)、`ocrNoAutoUpdate`(默认 false,即保留上游的自动升级,除非你明确关掉)、`ocrInstallHint`(`both`/`brew`/`npm`)。写在本插件注册行的 `config:` 块,改值随重启生效。
19
+
20
+ ## 需要外部提供什么
21
+
22
+ `ocr` 命令行,期望版本 **1.13.3**;这个期望写在代码里,并镜像到包清单与两份 README,所以版本不符是被报出来的,不是被掩盖的。装法是 `brew install open-code-review` 或 `npm i -g @alibaba-group/open-code-review@1.13.3`。命令行不存在时按「找不到命令」报;只有垫片没有真二进制时说的也是这一件。版本探测一律强制 `OCR_NO_UPDATE=1`,以免探测顺手触发升级。
23
+
24
+ API 凭据不存这里:本插件写的是一个 `api_key_cmd`,运行时的密钥由那支辅助脚本现取,不进配置文件。脚本缺失时卡上给出告警,而不是发一次没有鉴权的请求。
25
+
26
+ ## 更完整的说明
27
+
28
+ 暴露给模型的工具清单、后台评审如何落成宿主作业、参数守卫的旗标对照表都写在随包 README(`README.md` / `README.zh-CN.md`)。
package/host.js CHANGED
@@ -591,16 +591,16 @@ function arrayViolations(schema, value, path, walk) {
591
591
  * 能嵌套任意多个候选起点,而「run 内是否存在数字或大写」这类前瞻要把后面整段 run 反向
592
592
  * 回溯一遍——逐起点相乘就是 O(n²)(实测 400_000 字符 51 秒,正好是 `stdoutMaxBytes`
593
593
  * 的默认档)。run 是首个 `-` 之后的那一段:前缀只含字母,故首个 `-` 必是分界。 */
594
- const STANDALONE_KEY = /\b[A-Za-z]{2,5}-[A-Za-z0-9_-]{20,}\b/gu;
594
+ const STANDALONE_KEY = /\b[A-Za-z]{2,5}-[\w-]{20,}\b/gu;
595
595
  /** `Authorization: <scheme> <value>` / `Proxy-Authorization: …`:认**头名**而不是
596
596
  * 枚举认证方案。头名本身就是「后面跟着凭据」的信号,Basic / Token / Digest 乃至
597
597
  * vendor 自定义 scheme 都不会漏;而只认头名还避开了正文里「token based
598
598
  * authentication」这类措辞被误抹。scheme 组连同其后的空白一起捕获,回填时凭据被
599
599
  * 替换而 scheme 词保留。 */
600
- const AUTH_HEADER_VALUE = /(?<name>\b(?:proxy-)?authorization["'\s]*[:=]["'\s]*)(?:(?<scheme>[a-z][a-z0-9_-]*[ \t]+))?[^\s"',;)}\]]{4,}/giu;
600
+ const AUTH_HEADER_VALUE = /(?<name>\b(?:proxy-)?authorization["'\s]*[:=]["'\s]*)(?<scheme>[a-z][\w-]*[ \t]+)?[^\s"',;)}\]]{4,}/giu;
601
601
  /** 裸 scheme + 值(没有头名时 OCR 也会整行回显)。scheme 只留 bearer:它在散文中几乎
602
602
  * 不出现,而 token / basic 之类会撞上「token count」「basic usage」等普通措辞。 */
603
- const BEARER_VALUE = /(?<scheme>\bbearer[ \t]+)[a-z0-9._~+/=-]{4,}/giu;
603
+ const BEARER_VALUE = /(?<scheme>\bbearer[ \t]+)[\w.~+/=-]{4,}/giu;
604
604
  /** `api_key: "xxx"`、`apiKey=xxx`、`token: xxx`、`x-api-key: xxx` 等赋值形态。
605
605
  * 值下限取 4 且容许一个 scheme 词:`token: Basic <base64>` 里赋值形态先能吃到的只是
606
606
  * scheme 词(Basic 恰好 5 字符),下限更高时整条失配,后面那个 base64 就原样进了日志。
@@ -611,16 +611,25 @@ const BEARER_VALUE = /(?<scheme>\bbearer[ \t]+)[a-z0-9._~+/=-]{4,}/giu;
611
611
  *
612
612
  * 字符类写 `[A-Z0-9]` 而不是 `[A-Za-z0-9]`:这条正则带 `i` 标志,两者等价,
613
613
  * 而重复的字符区间会让静态检查报 duplicates-in-character-class。 */
614
- const KEY_ASSIGNMENT = /(?<name>(?<![A-Z0-9])(?:api[_-]?key|apikey|access[_-]?key|secret[_-]?key|client[_-]?secret|password|token)["'\s]*[:=]["'\s]*)(?:(?<scheme>[a-z][a-z0-9_-]*[ \t]+))?[^\s"',;)}\]]{4,}/giu;
614
+ const KEY_ASSIGNMENT = /(?<name>(?<![A-Z0-9])(?:api[_-]?key|access[_-]?key|secret[_-]?key|client[_-]?secret|password|token)["'\s]*[:=]["'\s]*)(?<scheme>[a-z][\w-]*[ \t]+)?[^\s"',;)}\]]{4,}/giu;
615
615
  /** GitHub 系 token 的**下划线**形态(`ghp_…`、`github_pat_…`):STANDALONE_KEY 要求
616
616
  * 「字母前缀 + 连字符」分界,这类值里没有连字符,前缀后又紧跟下划线,故单列一条。
617
617
  * 不并进 STANDALONE_KEY 是为了不放开那里的连字符要求——那条要求同时承担「别把
618
618
  * kebab 小写路径当 key 抹掉」的职责。 */
619
- const UNDERSCORE_TOKEN = /\b(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{20,}\b/gu;
619
+ const UNDERSCORE_TOKEN = /\b(?:gh[pousr]|github_pat)_\w{20,}\b/gu;
620
620
  const REDACTION = "***REDACTED***";
621
- /** 独立 key 形态的替换臂:随机串段里真有数字或大写才抹,否则原样交回(文件路径要保持可读)。 */
622
- function redactStandaloneKey(match) {
623
- return /[0-9A-Z]/u.test(match.slice(match.indexOf("-") + 1)) ? REDACTION : match;
621
+ /** token 尾部至少一枚非 `-` 的字母/数字。 */
622
+ const TOKEN_TAIL_CHARACTER = /[0-9A-Z]/u;
623
+ /** 独立 key 形态的替换臂:随机串段里真有数字或大写才抹,否则原样交回(文件路径要保持可读)。
624
+ * 两侧任一是路径分隔符的匹配是路径段而非 key:ocr CLI 会把仓库路径拍平成 kebab 段嵌进
625
+ * session 的 file_path(`…/sessions/Users-yuanjiang-Documents-…/uuid.jsonl`),原路径里
626
+ * 只要有大写组件(macOS 的 Documents/Desktop 一类)就过得了「数字或大写」那道护栏——
627
+ * 裸 key 从不出现在斜杠两侧,路径段也从不出现在别处,按上下文分比按字符集猜稳。 */
628
+ function redactStandaloneKey(match, offset, whole) {
629
+ const before = whole[offset - 1];
630
+ const after = whole[offset + match.length];
631
+ if (before === "/" || after === "/" || before === "\\" || after === "\\") return match;
632
+ return TOKEN_TAIL_CHARACTER.test(match.slice(match.indexOf("-") + 1)) ? REDACTION : match;
624
633
  }
625
634
  /**
626
635
  * 抹掉文本里 key 形状的片段。
@@ -693,6 +702,9 @@ function assertToolOutput(schema, value, toolName, messages) {
693
702
  return value;
694
703
  }
695
704
  // lib/argv-guard.ts
705
+ /** 归一后剥掉的尾随分隔符。 */
706
+ const TRAILING_POSIX_SLASHES = /\/+$/u;
707
+ const TRAILING_PATH_SEPARATORS = /[\\/]+$/u;
696
708
  /**
697
709
  * POSIX sh 单引号转义:' → '\''。把任意字符串安全放进单引号内,防注入。
698
710
  *
@@ -724,11 +736,11 @@ function assertAbsoluteRoot(root, messages) {
724
736
  if (trimmed.includes("\0")) throw new Error(format(messages.noNulBytes, { name: "repo" }));
725
737
  if (trimmed.startsWith("/")) {
726
738
  const normalized = path.posix.normalize(trimmed);
727
- return normalized.length > 1 ? normalized.replace(/\/+$/u, "") : normalized;
739
+ return normalized.length > 1 ? normalized.replace(TRAILING_POSIX_SLASHES, "") : normalized;
728
740
  }
729
741
  if (process.platform === "win32" && path.win32.isAbsolute(trimmed)) {
730
742
  const normalized = path.win32.normalize(trimmed);
731
- return normalized.length > 3 ? normalized.replace(/[\\/]+$/u, "") : normalized;
743
+ return normalized.length > 3 ? normalized.replace(TRAILING_PATH_SEPARATORS, "") : normalized;
732
744
  }
733
745
  throw new Error(format(messages.repoMustBeAbsolute, { root: trimmed }));
734
746
  }
@@ -887,8 +899,10 @@ function resolveOutputFormat(value) {
887
899
  return REVIEW_OUTPUT_FORMATS.find((candidate) => candidate === value) ?? "json";
888
900
  }
889
901
  // lib/cli.ts
890
- /** `ocr review|scan|delegate preview` 的背景说明 flag(三处命令构造共用一条)。
891
- * pushTextFlag 按 `flag.slice(2)` 反推报错用的参数名,故值必须带 `--` 前缀。 */
902
+ /** Homebrew 安装路径(`Cellar` / `Homebrew` 任一段)。 */
903
+ const HOMEBREW_PATH = /(?:^|[/\\])(?:Cellar|Homebrew)(?:[/\\]|$)/iu;
904
+ /** `ocr` 版本行:`open-code-review v1.13.3`(允许预发布/构建元数据)。 */
905
+ const OCR_VERSION = /open-code-review\s+v?(?<version>\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)/u;
892
906
  /** `--exclude` 的模式条数上限。**不是审查范围的上限**,只是防一条失控输入把命令串撑爆;
893
907
  * 真实使用里几十条就到头,给到 2000 是为了"尽量不截断"——超了会明确告警,不静默丢。 */
894
908
  const EXCLUDE_LIST_LIMIT = 2e3;
@@ -898,6 +912,8 @@ const PATH_LIST_LIMIT = 2e3;
898
912
  const RULE_FILE_MAX_BYTES = 262144;
899
913
  const BACKGROUND_FILE_MAX_BYTES = 1048576;
900
914
  const MAX_GIT_PROCS_FLAG = "--max-git-procs";
915
+ /** `ocr review|scan|delegate preview` 的背景说明 flag(三处命令构造共用一条)。
916
+ * pushTextFlag 按 `flag.slice(2)` 反推报错用的参数名,故值必须带 `--` 前缀。 */
901
917
  const BACKGROUND_FLAG = "--background";
902
918
  /**
903
919
  * 本包的契约是按外部 `ocr` CLI 的哪一版核对的(README 的前置条件、装法建议与工具回显里
@@ -1185,7 +1201,7 @@ function realPathOrSelf(candidate) {
1185
1201
  function inferOcrInstallChannel(candidate) {
1186
1202
  const resolved = realPathOrSelf(candidate);
1187
1203
  if (resolved.includes("node_modules")) return "npm";
1188
- return /(?:^|[/\\])(?:Cellar|Homebrew)(?:[/\\]|$)/iu.test(resolved) ? "brew" : "unknown";
1204
+ return HOMEBREW_PATH.test(resolved) ? "brew" : "unknown";
1189
1205
  }
1190
1206
  /** brew 的 formula 名与 npm 的包名不同:formula 不带 scope。 */
1191
1207
  const OCR_BREW_FORMULA = "open-code-review";
@@ -1324,7 +1340,7 @@ function buildVersionCommand() {
1324
1340
  * 读不出即 null:未发布构建的 `<Version>` 是 `dev`,没有版本号可报,猜一个等于编一个。
1325
1341
  */
1326
1342
  function parseOcrVersion(text) {
1327
- return /open-code-review\s+v?(?<version>\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)/u.exec(text)?.groups?.["version"] ?? null;
1343
+ return OCR_VERSION.exec(text)?.groups?.["version"] ?? null;
1328
1344
  }
1329
1345
  /**
1330
1346
  * `--format` 交给 OCR 的取值:本包只认 json——`lib/parse.ts` 的解析层只解 JSON 一份形状。
@@ -1729,6 +1745,8 @@ function aggregateComments(comments) {
1729
1745
  };
1730
1746
  }
1731
1747
  // lib/parse.ts
1748
+ /** 只认十进制整数的行号/计数文本。 */
1749
+ const DECIMAL_INTEGER = /^\d+$/u;
1732
1750
  /**
1733
1751
  * OCR 评审 JSON 的 status 枚举(判据在 CLI 侧 `cmd/opencodereview/output.go` 的 jsonOutput
1734
1752
  * 装配与 `internal/session/manifest.go` 的 TerminalState)。
@@ -1784,7 +1802,7 @@ function num(value) {
1784
1802
  if (typeof value === "number") return nonNegativeInt(value);
1785
1803
  if (typeof value === "string") {
1786
1804
  const trimmed = value.trim();
1787
- if (!/^\d+$/u.test(trimmed)) return 0;
1805
+ if (!DECIMAL_INTEGER.test(trimmed)) return 0;
1788
1806
  return nonNegativeInt(Number(trimmed));
1789
1807
  }
1790
1808
  return 0;
@@ -1849,7 +1867,7 @@ function capLines(lines, total, messages) {
1849
1867
  * 否则 "x" 或 -1 会被 num 折成 0,把「读不出」伪装成「零失败」)。 */
1850
1868
  function isCount(value) {
1851
1869
  if (typeof value === "number") return Number.isSafeInteger(value) && value >= 0;
1852
- return typeof value === "string" && /^\d+$/u.test(value.trim());
1870
+ return typeof value === "string" && DECIMAL_INTEGER.test(value.trim());
1853
1871
  }
1854
1872
  /**
1855
1873
  * 解析 `tool_calls`。字段缺失或为 null(这一场没有工具调用记录)返回 null;
@@ -2931,6 +2949,8 @@ const ROUTING_ORDER = 1555;
2931
2949
  // lib/provider-projection.ts
2932
2950
  /** llm-pi-ai 命名空间里 provider 表的字段名。 */
2933
2951
  const PROVIDERS_FIELD = "providers";
2952
+ /** provider 的 baseURL 只认 http(s)。 */
2953
+ const HTTP_URL$1 = /^https?:\/\//iu;
2934
2954
  /**
2935
2955
  * llm-pi-ai 的 `api` 协议 → OCR config 的 `protocol` 映射。
2936
2956
  *
@@ -3005,7 +3025,7 @@ function providerFromRaw(name, raw) {
3005
3025
  const api = apiKeyOf(entry["api"]);
3006
3026
  const protocol = api === null ? "openai" : OCR_PROTOCOL_BY_API.get(api) ?? api;
3007
3027
  const baseURL = typeof entry["baseURL"] === "string" ? entry["baseURL"].trim() : "";
3008
- if (!baseURL || !/^https?:\/\//iu.test(baseURL)) return null;
3028
+ if (!baseURL || !HTTP_URL$1.test(baseURL)) return null;
3009
3029
  const modelsRaw = Array.isArray(entry["models"]) ? entry["models"] : [];
3010
3030
  const models = modelsRaw.map((model) => modelFromRaw(model)).filter((model) => model !== null);
3011
3031
  if (modelsRaw.length > 0 && models.length === 0) return null;
@@ -3156,6 +3176,11 @@ async function markKeyFlags(gateway, providers) {
3156
3176
  // lib/config-store.ts
3157
3177
  /** api_key_cmd 脚本名(pluginDir 兜底路径的唯一拼法来源)。 */
3158
3178
  const GET_CRED_SCRIPT = path.join("scripts", "get-cred.mjs");
3179
+ /** 尾部反斜杠 / 翻倍反斜杠 / env 键 / 行首 node 命令。 */
3180
+ const TRAILING_BACKSLASHES = /\\+$/u;
3181
+ const DOUBLED_BACKSLASHES = /(?:\\\\)+$/u;
3182
+ const ENV_KEY = /^[A-Z0-9_]+$/u;
3183
+ const LEADING_NODE_CMD = /^node[ \t]+/u;
3159
3184
  /** 未知值 → 安全字符串(非字符串给 '')。 */
3160
3185
  function strOf(value) {
3161
3186
  return typeof value === "string" ? value : "";
@@ -3228,7 +3253,7 @@ function readExistingText(configPath, messages) {
3228
3253
  * 保证常规路径形态正确)。
3229
3254
  */
3230
3255
  function shqDyn(value) {
3231
- if (process.platform === "win32") return `"${value.replaceAll("\"", "\"\"").replace(/\\+$/u, (backslashes) => backslashes + backslashes)}"`;
3256
+ if (process.platform === "win32") return `"${value.replaceAll("\"", "\"\"").replace(TRAILING_BACKSLASHES, (backslashes) => backslashes + backslashes)}"`;
3232
3257
  return `'${value.replaceAll("'", String.raw`'\''`)}'`;
3233
3258
  }
3234
3259
  /** api_key_cmd 值构造:脚本路径与 env key 名(及可选的 dsh 数据目录)shq 转义后
@@ -3239,7 +3264,7 @@ function shqDyn(value) {
3239
3264
  * env key 白名单维持 ^[A-Z0-9_]+$(刻意比宿主 CredentialRef 的语法紧:OCR 侧
3240
3265
  * 惯例大写,且这是写进 config.json 的可信输入)。 */
3241
3266
  function buildApiKeyCmd(scriptPath, envKey, dshHome, messages) {
3242
- if (!/^[A-Z0-9_]+$/u.test(envKey)) throw new Error(format(messages.badEnvKey, { key: envKey }));
3267
+ if (!ENV_KEY.test(envKey)) throw new Error(format(messages.badEnvKey, { key: envKey }));
3243
3268
  const parts = [
3244
3269
  "node",
3245
3270
  shqDyn(scriptPath),
@@ -3319,7 +3344,7 @@ function readDqToken(body, start) {
3319
3344
  }
3320
3345
  if (!closed) return null;
3321
3346
  return {
3322
- value: value.replace(/(?:\\\\)+$/u, (backslashes) => "\\".repeat(backslashes.length / 2)),
3347
+ value: value.replace(DOUBLED_BACKSLASHES, (backslashes) => "\\".repeat(backslashes.length / 2)),
3323
3348
  next: i
3324
3349
  };
3325
3350
  }
@@ -3361,13 +3386,13 @@ function readDqTokens(body) {
3361
3386
  * 把用户手写的凭据取用方式弄坏,而那正是本包明令不该插手的部分。
3362
3387
  */
3363
3388
  function parseApiKeyCmd(command) {
3364
- const body = command.replace(/^node[ \t]+/u, "");
3389
+ const body = command.replace(LEADING_NODE_CMD, "");
3365
3390
  if (body === command) return null;
3366
3391
  const tokens = readShqTokens(body) ?? readDqTokens(body);
3367
3392
  if (tokens === null) return null;
3368
3393
  const script = tokens[0] ?? "";
3369
3394
  const envKey = tokens[1] ?? "";
3370
- if (script === "" || tokens.length < 2 || tokens.length > 3 || !/^[A-Z0-9_]+$/u.test(envKey)) return null;
3395
+ if (script === "" || tokens.length < 2 || tokens.length > 3 || !ENV_KEY.test(envKey)) return null;
3371
3396
  return {
3372
3397
  script,
3373
3398
  envKey,
@@ -3818,6 +3843,8 @@ const OCR_REMOTE_NAMESPACE = "ocrReview";
3818
3843
  // lib/mcp-config.ts
3819
3844
  /** 整体上限:MCP 配置是一份人工维护的小表,不承载大文件。 */
3820
3845
  const MCP_MAX_BYTES = 65536;
3846
+ /** remote MCP server 的 url 只认 http(s)。 */
3847
+ const HTTP_URL = /^https?:\/\//iu;
3821
3848
  const MCP_MAX_SERVERS = 100;
3822
3849
  const MCP_MAX_ARRAY = 100;
3823
3850
  const MCP_MAX_TEXT = 4096;
@@ -3907,7 +3934,7 @@ function normalizeRemote(raw, name, messages) {
3907
3934
  if (raw["command"] !== void 0 || raw["args"] !== void 0 || raw["env"] !== void 0 || raw["setup"] !== void 0) return failure(name, "type", "remote does not accept command/args/env/setup", messages);
3908
3935
  const url = textField(raw["url"], name, "url", messages);
3909
3936
  if (!url.ok) return url;
3910
- if (!/^https?:\/\//iu.test(url.value)) return failure(name, "url", "must start with http:// or https://", messages);
3937
+ if (!HTTP_URL.test(url.value)) return failure(name, "url", "must start with http:// or https://", messages);
3911
3938
  const entry = {
3912
3939
  type: "remote",
3913
3940
  url: url.value
@@ -4118,10 +4145,13 @@ function isLiveOcrStatus(status) {
4118
4145
  * @param jobs 官方注册表
4119
4146
  * @returns {void}
4120
4147
  */
4121
- function pruneOcrJobHistory(jobs) {
4122
- const settled = jobs.list().filter((job) => job.kind === OCR_JOB_KIND && !isLiveOcrStatus(job.status));
4148
+ function pruneOcrJobHistory(jobs, owner, ledger) {
4149
+ const settled = jobs.list(owner).filter((job) => job.kind === OCR_JOB_KIND && !isLiveOcrStatus(job.status));
4123
4150
  const excess = settled.length - 9;
4124
- for (const job of settled.slice(0, Math.max(excess, 0))) jobs.remove(job.id);
4151
+ for (const job of settled.slice(0, Math.max(excess, 0))) {
4152
+ jobs.remove(job.id, owner);
4153
+ ledger.delete(job.id);
4154
+ }
4125
4155
  }
4126
4156
  /**
4127
4157
  * 在 controller 闸门下起一条未拥有作业,起完立刻摘掉。
@@ -4169,10 +4199,44 @@ async function enforceOcrDeadline(jobs, id, reason, backgroundMaxMs) {
4169
4199
  * @param reason 写进模型可见 detail 的理由(双语字典取)
4170
4200
  * @returns {void}
4171
4201
  */
4172
- function reclaimOcrJobs(jobs, reason) {
4173
- const mine = jobs.list().filter((job) => job.kind === OCR_JOB_KIND);
4174
- for (const job of mine) if (isLiveOcrStatus(job.status)) jobs.kill(job.id, void 0, reason);
4175
- else jobs.remove(job.id);
4202
+ function reclaimSessionJobs(jobs, reason, owner, ledger) {
4203
+ for (const job of jobs.list(owner)) if (job.kind === OCR_JOB_KIND && job.owner === owner) {
4204
+ ledger.delete(job.id);
4205
+ if (isLiveOcrStatus(job.status)) jobs.kill(job.id, owner, reason);
4206
+ else jobs.remove(job.id, owner);
4207
+ }
4208
+ }
4209
+ /** 卸载时收一条有主作业:id 只有台账知道,kill/remove 必须带 owner 作 caller。 */
4210
+ function reclaimOwnedJob(jobs, id, owner, reason) {
4211
+ try {
4212
+ if (isLiveOcrStatus(jobs.get(id, owner).status)) jobs.kill(id, owner, reason);
4213
+ else jobs.remove(id, owner);
4214
+ } catch {}
4215
+ }
4216
+ /**
4217
+ * 卸载时收掉本包起过的全部作业:无主的经 `list()` 枚举,有主的只有台账知道 id
4218
+ * (注册表对没有 caller 的视图隐藏它们,且 kill/remove 必须带 owner 作 caller)。
4219
+ */
4220
+ function reclaimAllJobs(jobs, reason, ledger) {
4221
+ for (const job of jobs.list()) if (job.kind === OCR_JOB_KIND) {
4222
+ if (isLiveOcrStatus(job.status)) jobs.kill(job.id, void 0, reason);
4223
+ else jobs.remove(job.id);
4224
+ }
4225
+ const owned = new Map(ledger);
4226
+ ledger.clear();
4227
+ for (const [id, owner] of owned) if (owner !== void 0) reclaimOwnedJob(jobs, id, owner, reason);
4228
+ }
4229
+ /** 工具调用发起时的会话 id;形状不对就当没有(agentless)。 */
4230
+ function sessionIdOf(exec) {
4231
+ const id = exec.agent?.session?.id;
4232
+ return typeof id === "string" && id.length > 0 ? id : void 0;
4233
+ }
4234
+ /** 该调用可绑定的作业 owner:会话 id 存在、且宿主注册表里确有这条活 Agent。
4235
+ * 给不出活 Agent 时退回 agentless —— 拿一个注册表不认的 id 去 start 只会整条失败。 */
4236
+ function ownerOf(host, exec) {
4237
+ const id = sessionIdOf(exec);
4238
+ const agents = host.get("agents");
4239
+ return id !== void 0 && agents?.get(id) !== void 0 ? id : void 0;
4176
4240
  }
4177
4241
  /** 当前会话工作区(repo 缺省回退源);非字符串视为缺。
4178
4242
  * 官方形状:`agent?: Agent` → `session: Session` → `header: SessionHeader` →
@@ -4393,7 +4457,7 @@ async function outcomeWhenSettled(proc) {
4393
4457
  * @param job 这次后台评审的描述
4394
4458
  * @returns 后台启动回执(canonical 对象,模型侧契约)
4395
4459
  */
4396
- async function startBackground(host, job) {
4460
+ async function startBackground(host, job, ledger) {
4397
4461
  const registry = host.get("jobs");
4398
4462
  if (registry === void 0) throw new Error("background review unavailable: the host provides no ctx.jobs (load @deepseek-ai/dsh-jobs-local); run it with wait:true instead");
4399
4463
  const spec = host.shell.resolve({
@@ -4405,13 +4469,14 @@ async function startBackground(host, job) {
4405
4469
  sandboxPolicy: ocrSandboxPolicy(job.workdir)
4406
4470
  });
4407
4471
  const proc = await host.shell.execute(spec);
4408
- pruneOcrJobHistory(registry);
4472
+ pruneOcrJobHistory(registry, job.owner, ledger);
4409
4473
  const messages = localeMessages(host);
4410
4474
  let jobId;
4411
4475
  try {
4412
4476
  jobId = startUnderOcrController(registry, {
4413
4477
  kind: OCR_JOB_KIND,
4414
4478
  label: `${job.tool} ${job.repo}`,
4479
+ ...job.owner === void 0 ? {} : { owner: job.owner },
4415
4480
  output: reviewSources(proc, messages),
4416
4481
  run: () => ({
4417
4482
  cancel: () => {
@@ -4424,6 +4489,7 @@ async function startBackground(host, job) {
4424
4489
  killProc(proc);
4425
4490
  throw error;
4426
4491
  }
4492
+ ledger.set(jobId, job.owner);
4427
4493
  const backstopMinutes = Math.round(job.backgroundMaxMs / 6e4);
4428
4494
  enforceOcrDeadline(registry, jobId, format(messages.jobReasonTimeout, { minutes: String(backstopMinutes) }), job.backgroundMaxMs);
4429
4495
  return {
@@ -5359,7 +5425,7 @@ async function runScanPreview(run) {
5359
5425
  });
5360
5426
  }
5361
5427
  }
5362
- function registerReviewTool(host, config, probe, versionSink) {
5428
+ function registerReviewTool(host, config, probe, versionSink, ledger) {
5363
5429
  const msgs = localeMessages(host);
5364
5430
  define(host, {
5365
5431
  name: "ocr_review",
@@ -5481,10 +5547,11 @@ function registerReviewTool(host, config, probe, versionSink) {
5481
5547
  workdir,
5482
5548
  tool: "ocr_review",
5483
5549
  repo,
5550
+ owner: ownerOf(host, exec),
5484
5551
  stdoutMaxBytes: config.stdoutMaxBytes,
5485
5552
  backgroundMaxMs: config.ocrBackgroundMaxMs,
5486
5553
  env: ocrEnv(config)
5487
- });
5554
+ }, ledger);
5488
5555
  try {
5489
5556
  await runForeground(host, guarded, workdir, tuningOf(config), exec.signal);
5490
5557
  } catch (error) {
@@ -5508,7 +5575,7 @@ function registerReviewTool(host, config, probe, versionSink) {
5508
5575
  }
5509
5576
  });
5510
5577
  }
5511
- function registerScanTool(host, config, probe, versionSink) {
5578
+ function registerScanTool(host, config, probe, versionSink, ledger) {
5512
5579
  const msgs = localeMessages(host);
5513
5580
  define(host, {
5514
5581
  name: "ocr_scan",
@@ -5616,10 +5683,11 @@ function registerScanTool(host, config, probe, versionSink) {
5616
5683
  workdir,
5617
5684
  tool: "ocr_scan",
5618
5685
  repo,
5686
+ owner: ownerOf(host, exec),
5619
5687
  stdoutMaxBytes: config.stdoutMaxBytes,
5620
5688
  backgroundMaxMs: config.ocrBackgroundMaxMs,
5621
5689
  env: ocrEnv(config)
5622
- });
5690
+ }, ledger);
5623
5691
  try {
5624
5692
  await runForeground(host, guarded, workdir, tuningOf(config), exec.signal);
5625
5693
  } catch (error) {
@@ -6006,26 +6074,29 @@ function apply(ctx, config, probe) {
6006
6074
  const host = resolveHost(ctx);
6007
6075
  const endpointVerify = { value: null };
6008
6076
  const versionSink = { value: null };
6077
+ const jobLedger = /* @__PURE__ */ new Map();
6009
6078
  if (config.startupVerify) {
6010
6079
  probeEndpointAtApply(host, config, endpointVerify);
6011
6080
  probeVersionAtApply(host, config, versionSink);
6012
6081
  }
6013
6082
  ctx.effect(() => () => {
6014
6083
  const jobs = ctx.get("jobs");
6015
- if (jobs !== void 0) reclaimOcrJobs(jobs, localeMessages(ctx).jobReasonUnload);
6084
+ if (jobs !== void 0) reclaimAllJobs(jobs, localeMessages(ctx).jobReasonUnload, jobLedger);
6016
6085
  }, "ocr-review: background jobs");
6017
- ctx.effect(() => ctx.on("session/disposed", () => {
6086
+ ctx.effect(() => ctx.on("session/disposed", (session) => {
6018
6087
  const jobs = ctx.get("jobs");
6019
6088
  if (jobs === void 0) return;
6089
+ const owner = session.id;
6090
+ if (typeof owner !== "string" || owner.length === 0) return;
6020
6091
  try {
6021
- reclaimOcrJobs(jobs, localeMessages(ctx).jobReasonSessionStopped);
6092
+ reclaimSessionJobs(jobs, localeMessages(ctx).jobReasonSessionStopped, owner, jobLedger);
6022
6093
  } catch {}
6023
6094
  }), "ocr-review: session stop reaps background runs");
6024
6095
  host.inject(["settings"], (child) => {
6025
6096
  child.effect(() => child.settings.configure({ auto: false }, host.fiber));
6026
6097
  });
6027
- registerReviewTool(host, config, probe, versionSink);
6028
- registerScanTool(host, config, probe, versionSink);
6098
+ registerReviewTool(host, config, probe, versionSink, jobLedger);
6099
+ registerScanTool(host, config, probe, versionSink, jobLedger);
6029
6100
  registerDelegatePreviewTool(host, config, probe);
6030
6101
  registerDelegateRuleTool(host, config, probe);
6031
6102
  registerSessionTool(host, config, probe);
package/locale/en.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "meta": {
3
+ "title": "Open code review",
4
+ "description": "Runs the external ocr CLI over a change set, reports its review comments, and manages the model it reviews with.",
5
+ "detail": "docs/DETAIL.md"
6
+ }
7
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "meta": {
3
+ "title": "代码评审(open-code-review)",
4
+ "description": "调用外部 ocr 命令行跑变更集评审,回报它给出的意见,并管理评审所用的模型。",
5
+ "detail": "docs/DETAIL.zh.md"
6
+ }
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jayyuen66/dsh-ocr-review",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "dsh 工作区代码审查插件:把 open-code-review(ocr CLI)封装为 7 个模型工具 + 设置卡片(从宿主设置服务的 provider 清单选 provider/model 应用到 OCR 配置)。",
5
5
  "keywords": [
6
6
  "ai-code-review",
@@ -22,6 +22,9 @@
22
22
  "url": "git+https://github.com/JayYuen666/dsh-ocr-review.git"
23
23
  },
24
24
  "files": [
25
+ "locale/zh.json",
26
+ "locale/en.json",
27
+ "docs",
25
28
  "icon.svg",
26
29
  "host.js",
27
30
  "client.js",
@@ -40,6 +43,7 @@
40
43
  ".": "./host.js",
41
44
  "./client": "./client.js",
42
45
  "./cordis.patch.yml": "./cordis.patch.yml",
46
+ "./locale/*.json": "./locale/*.json",
43
47
  "./package.json": "./package.json"
44
48
  },
45
49
  "publishConfig": {
@@ -47,47 +51,51 @@
47
51
  "registry": "https://registry.npmjs.org"
48
52
  },
49
53
  "dependencies": {
50
- "@jayyuen66/dsh-plugin-shared": "0.1.9",
51
- "js-yaml": "^5.4.3"
54
+ "@jayyuen66/dsh-plugin-shared": "0.2.1",
55
+ "js-yaml": "^5.4.3",
56
+ "zod": "4.6.7"
52
57
  },
53
58
  "devDependencies": {
54
- "@deepseek-ai/cordis": "4.0.5",
55
- "@deepseek-ai/dsh-agent": "0.2.1-rc.3",
56
- "@deepseek-ai/dsh-api-remotes": "0.2.1-rc.3",
57
- "@deepseek-ai/dsh-atomic-write": "0.2.1-rc.3",
58
- "@deepseek-ai/dsh-client-locale": "0.2.1-rc.3",
59
- "@deepseek-ai/dsh-client-store": "0.2.1-rc.3",
60
- "@deepseek-ai/dsh-client-ui-layout": "0.2.1-rc.3",
61
- "@deepseek-ai/dsh-client-ui-plugin-manager": "0.2.1-rc.3",
62
- "@deepseek-ai/dsh-client-ui-renderer": "0.2.1-rc.3",
63
- "@deepseek-ai/dsh-client-ui-settings": "0.2.1-rc.3",
64
- "@deepseek-ai/dsh-client-ui-slots": "0.2.1-rc.3",
65
- "@deepseek-ai/dsh-credentials": "0.2.1-rc.3",
66
- "@deepseek-ai/dsh-home-paths": "0.2.1-rc.3",
67
- "@deepseek-ai/dsh-host-webserver": "0.2.1-rc.3",
68
- "@deepseek-ai/dsh-jobs": "0.2.1-rc.3",
69
- "@deepseek-ai/dsh-jobs-local": "0.2.1-rc.3",
70
- "@deepseek-ai/dsh-output-retention": "0.2.1-rc.3",
71
- "@deepseek-ai/dsh-package-manifest": "0.2.1-rc.3",
72
- "@deepseek-ai/dsh-ptc-runtime": "0.2.1-rc.3",
73
- "@deepseek-ai/dsh-sandbox": "0.2.1-rc.3",
74
- "@deepseek-ai/dsh-scope": "0.2.1-rc.3",
75
- "@deepseek-ai/dsh-session": "0.2.1-rc.3",
76
- "@deepseek-ai/dsh-session-projection": "0.2.1-rc.3",
77
- "@deepseek-ai/dsh-settings": "0.2.1-rc.3",
78
- "@deepseek-ai/dsh-shell": "0.2.1-rc.3",
79
- "@deepseek-ai/dsh-subprocess": "0.2.1-rc.3",
80
- "@deepseek-ai/dsh-system-prompt": "0.2.1-rc.3",
81
- "@deepseek-ai/dsh-tools": "0.2.1-rc.3",
82
- "@deepseek-ai/dsh-typert-protocol": "0.2.1-rc.3",
83
- "@deepseek-ai/dsh-user-approval": "0.2.1-rc.3",
84
- "@deepseek-ai/schemastery": "3.18.5",
85
- "@types/node": "26.6.5",
59
+ "@deepseek-ai/cordis": "4.0.6-alpha.1",
60
+ "@deepseek-ai/dsh-agent": "0.2.2-alpha.2",
61
+ "@deepseek-ai/dsh-api-remotes": "0.2.2-alpha.2",
62
+ "@deepseek-ai/dsh-atomic-write": "0.2.2-alpha.2",
63
+ "@deepseek-ai/dsh-client-locale": "0.2.2-alpha.2",
64
+ "@deepseek-ai/dsh-client-store": "0.2.2-alpha.2",
65
+ "@deepseek-ai/dsh-client-ui-layout": "0.2.2-alpha.2",
66
+ "@deepseek-ai/dsh-client-ui-plugin-manager": "0.2.2-alpha.2",
67
+ "@deepseek-ai/dsh-client-ui-renderer": "0.2.2-alpha.2",
68
+ "@deepseek-ai/dsh-client-ui-settings": "0.2.2-alpha.2",
69
+ "@deepseek-ai/dsh-client-ui-slots": "0.2.2-alpha.2",
70
+ "@deepseek-ai/dsh-credentials": "0.2.2-alpha.2",
71
+ "@deepseek-ai/dsh-home-paths": "0.2.2-alpha.2",
72
+ "@deepseek-ai/dsh-host-webserver": "0.2.2-alpha.2",
73
+ "@deepseek-ai/dsh-jobs": "0.2.2-alpha.2",
74
+ "@deepseek-ai/dsh-jobs-local": "0.2.2-alpha.2",
75
+ "@deepseek-ai/dsh-output-retention": "0.2.2-alpha.2",
76
+ "@deepseek-ai/dsh-package-manifest": "0.2.2-alpha.2",
77
+ "@deepseek-ai/dsh-ptc-runtime": "0.2.2-alpha.2",
78
+ "@deepseek-ai/dsh-sandbox": "0.2.2-alpha.2",
79
+ "@deepseek-ai/dsh-scope": "0.2.2-alpha.2",
80
+ "@deepseek-ai/dsh-session": "0.2.2-alpha.2",
81
+ "@deepseek-ai/dsh-session-projection": "0.2.2-alpha.2",
82
+ "@deepseek-ai/dsh-settings": "0.2.2-alpha.2",
83
+ "@deepseek-ai/dsh-shell": "0.2.2-alpha.2",
84
+ "@deepseek-ai/dsh-subprocess": "0.2.2-alpha.2",
85
+ "@deepseek-ai/dsh-system-prompt": "0.2.2-alpha.2",
86
+ "@deepseek-ai/dsh-tools": "0.2.2-alpha.2",
87
+ "@deepseek-ai/dsh-typert-protocol": "0.2.2-alpha.2",
88
+ "@deepseek-ai/dsh-user-approval": "0.2.2-alpha.2",
89
+ "@deepseek-ai/schemastery": "3.18.6-alpha.1",
90
+ "@e18e/eslint-plugin": "0.8.1",
91
+ "@types/node": "26.6.6",
86
92
  "@types/react": "19.3.0",
87
93
  "@vitest/coverage-v8": "5.0.5",
94
+ "eslint-plugin-regexp": "3.3.2",
88
95
  "eslint-plugin-sonarjs": "4.2.2",
89
- "oxfmt": "0.74.0",
90
- "oxlint": "1.89.0",
96
+ "eslint-plugin-zod": "5.2.2",
97
+ "oxfmt": "0.75.0",
98
+ "oxlint": "1.90.0",
91
99
  "oxlint-tsgolint": "7.0.2005",
92
100
  "react": "^19.3.0",
93
101
  "rolldown": "1.2.15",
@@ -96,11 +104,11 @@
96
104
  "vitest": "5.0.5"
97
105
  },
98
106
  "peerDependencies": {
99
- "@deepseek-ai/dsh-atomic-write": "0.2.1-rc.3",
100
- "@deepseek-ai/dsh-credentials": "0.2.1-rc.3",
101
- "@deepseek-ai/dsh-home-paths": "0.2.1-rc.3",
102
- "@deepseek-ai/dsh-typert-protocol": "0.2.1-rc.3",
103
- "@deepseek-ai/schemastery": "3.18.5"
107
+ "@deepseek-ai/dsh-atomic-write": "^0.2.1-rc.3",
108
+ "@deepseek-ai/dsh-credentials": "^0.2.1-rc.3",
109
+ "@deepseek-ai/dsh-home-paths": "^0.2.1-rc.3",
110
+ "@deepseek-ai/dsh-typert-protocol": "^0.2.1-rc.3",
111
+ "@deepseek-ai/schemastery": "3.18.6-alpha.1"
104
112
  },
105
113
  "icon": "icon.svg",
106
114
  "engines": {