dsh-decision-tools 0.3.0 → 0.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 — 2026-10-19
4
+
5
+ - Image material: `material.images` accepts 1–8 workspace-relative PNG/JPEG/WebP paths; `material.text` combines inline text with images; images are magic-checked, base64-encoded, and never enter the agent context.
6
+ - Image calls use a vision model (`clef` / `clef-flash`, passed via `model` or the decision row `modelOverride`), a 32 MiB body limit, and a single request (no escalation/batching/scanning; `depth` ignored).
7
+ - Output `material.images` reports count/bytes/encodedBytes/formats.
8
+ - Renamed `docs/architecture-current.md` to `docs/architecture.md` and updated references.
9
+ - CI test dependencies are devDependencies; `npm install --ignore-scripts` now installs them on both Node 22.19 and 24.
10
+
3
11
  ## 0.3.0 — 2026-10-19
4
12
 
5
13
  First public release.
package/README.md CHANGED
@@ -20,6 +20,7 @@ Judgements run on **your own Ollama** (`/v1/systemone`). Nothing goes to a cloud
20
20
  - DSH desktop with this plugin enabled (see Install).
21
21
  - A local Ollama serving the decision model — default `tev1:0.8b`, override per call with `model`.
22
22
  - No API key; the judgement itself makes no outbound call.
23
+ - For image material: a vision-capable model (`clef` / `clef-flash`). `tev1` / `nimble` return `does not support vision`.
23
24
 
24
25
  ## Install
25
26
 
@@ -80,7 +81,12 @@ node scripts/list-models.mjs --json # { models, ladder }
80
81
  { "material": { "tool": { "name": "git_bash", "arguments": { "command": "npm test" } } },
81
82
  "rubric": "Did the test suite pass?", "items": ["the test suite output"], "type": "noul" }
82
83
 
83
- // 4) Wait for a background job, and get a notice on a hit
84
+ // 4) Judge an image (needs a vision model)
85
+ { "material": { "images": ["screenshots/checkout-error.png"] },
86
+ "model": "clef-flash:latest",
87
+ "questions": [ { "name": "has_error", "type": "noul", "instructions": "Does this screenshot show an error state?" } ] }
88
+
89
+ // 5) Wait for a background job, and get a notice on a hit
84
90
  { "source": { "kind": "job", "id": "<jobId>" },
85
91
  "rubric": "Did the job finish successfully?", "background": true, "notify": "inbox" }
86
92
  ```
@@ -96,16 +102,18 @@ Give `material` plus **exactly one** mode:
96
102
 
97
103
  | Parameter | Required | Notes |
98
104
  |---|---|---|
99
- | `material` | yes | Inline string, `{file}`, `{glob}`, or `{tool}` |
105
+ | `material` | yes | Inline string, `{file}`, `{glob}`, `{tool}`, `{images}`, or `{text, images}` |
100
106
  | `rubric` | batch | The one shared criterion; the wire layer repeats it for the model |
101
107
  | `items` | batch | 1–64 self-contained items; plain strings are valid |
102
108
  | `questions` | mixed | 1–64 `{name, type, instructions, criteria?}` |
103
109
  | `type` | batch | `choice` / `noul` / `score` (mixed: per question) |
104
110
  | `criteria` | choice/score | Labels once: top-level in batch, per question in mixed |
105
- | `depth` | no | `quick` (no escalation/batching), `normal` (default), `deep` (may chunk) |
111
+ | `depth` | no | `quick` (no escalation/batching), `normal` (default), `deep` (may chunk); ignored for images |
106
112
  | `model` | no | Pin a model; disables capacity choice and escalation |
107
113
 
108
- **Material forms**: inline text · `{ file: { path, startLine?, endLine? } }` · `{ glob: { pattern, limit? } }` · `{ tool: { name, arguments? } }`. A tool's raw output never enters your context; the full text is spilled and returned as `material.spillPath`. `tool` cannot be combined with `file`/`glob`, and the plugin's own tools are refused.
114
+ **Material forms**: inline text · `{ file: { path, startLine?, endLine? } }` · `{ glob: { pattern, limit? } }` · `{ images: ["a.png"] }` (or `{ text, images }`) · `{ tool: { name, arguments? } }`. A tool's raw output never enters your context; the full text is spilled and returned as `material.spillPath`. `text`/`file`/`glob` may be combined with `images`; `tool` cannot be combined with any other form, and the plugin's own tools are refused.
115
+
116
+ **Images**: workspace-relative PNG/JPEG/WebP paths (1–8; ≤16 MiB each, ≤24 MiB total raw, request body ≤32 MiB). The plugin reads, magic-checks and base64-encodes them; the bytes never enter your context. Image requests require a vision model and run as a single request — `depth` is ignored (no escalation, batching or scanning). Output includes `material.images` (`count`/`bytes`/`encodedBytes`/`formats`).
109
117
 
110
118
  **Read the result**: `results[]` (`id`, `type`, `value`, `probabilities?`, `confidence?`) · `coverage` (`requested`/`answered`/`batches`/`truncated`/`truncatedBy`/`failed`/`aborted`) · `cost` (`model`/`inputTokens`/`bodyBytes`/`wallMs`/`escalated`/`attempts`) · `notes` (first line = model-selection decision).
111
119
 
@@ -232,4 +240,4 @@ MIT — see [LICENSE](./LICENSE).
232
240
 
233
241
  ## No compatibility layer
234
242
 
235
- This is a one-time structural upgrade, not a version transition. Old row ids (`decision-tools-tool`) and old config keys (`decisionEnabled` / `monitorEnabled`) are not accepted; use the four rows and three Config tables above. See [docs/architecture-current.md](./docs/architecture-current.md) for the as-is contract and [docs/use-cases.md](./docs/use-cases.md) for worked examples.
243
+ This is a one-time structural upgrade, not a version transition. Old row ids (`decision-tools-tool`) and old config keys (`decisionEnabled` / `monitorEnabled`) are not accepted; use the four rows and three Config tables above. See [docs/architecture.md](./docs/architecture.md) for the as-is contract and [docs/use-cases.md](./docs/use-cases.md) for worked examples.
package/README.zh.md CHANGED
@@ -20,6 +20,7 @@
20
20
  - 已启用本插件的 DSH 桌面版(见「安装」)。
21
21
  - 本地 Ollama 提供 decision 模型 —— 默认 `tev1:0.8b`,可用 `model` 单次覆盖。
22
22
  - 无需 API Key;判定本身不出网。
23
+ - 图片 material 需要视觉模型(`clef` / `clef-flash`);`tev1` / `nimble` 会回 `does not support vision`。
23
24
 
24
25
  ## 安装
25
26
 
@@ -80,7 +81,12 @@ node scripts/list-models.mjs --json # { models, ladder }
80
81
  { "material": { "tool": { "name": "git_bash", "arguments": { "command": "npm test" } } },
81
82
  "rubric": "Did the test suite pass?", "items": ["the test suite output"], "type": "noul" }
82
83
 
83
- // 4) 等后台作业结束,并让插件通知你
84
+ // 4) 判图片(需要视觉模型)
85
+ { "material": { "images": ["screenshots/checkout-error.png"] },
86
+ "model": "clef-flash:latest",
87
+ "questions": [ { "name": "has_error", "type": "noul", "instructions": "Does this screenshot show an error state?" } ] }
88
+
89
+ // 5) 等后台作业结束,并让插件通知你
84
90
  { "source": { "kind": "job", "id": "<jobId>" },
85
91
  "rubric": "Did the job finish successfully?", "background": true, "notify": "inbox" }
86
92
  ```
@@ -96,16 +102,18 @@ node scripts/list-models.mjs --json # { models, ladder }
96
102
 
97
103
  | 参数 | 必填 | 说明 |
98
104
  |---|---|---|
99
- | `material` | 是 | 内联字符串 / `{file}` / `{glob}` / `{tool}` |
105
+ | `material` | 是 | 内联字符串 / `{file}` / `{glob}` / `{tool}` / `{images}` / `{text, images}` |
100
106
  | `rubric` | 批量 | 唯一共享判据;线格式会替模型重复它 |
101
107
  | `items` | 批量 | 1–64 个自包含项;直接传字符串也合法 |
102
108
  | `questions` | 混合 | 1–64 个 `{name, type, instructions, criteria?}` |
103
109
  | `type` | 批量 | `choice` / `noul` / `score`(混合模式逐题给) |
104
110
  | `criteria` | choice/score | 标签集只写一次:批量走顶层,混合走每题 |
105
- | `depth` | 否 | `quick`(不升档不分批)、`normal`(默认)、`deep`(可切块) |
111
+ | `depth` | 否 | `quick`(不升档不分批)、`normal`(默认)、`deep`(可切块);图片请求忽略 |
106
112
  | `model` | 否 | 固定模型;关闭容量选择与升档 |
107
113
 
108
- **材料四形态**:内联文本 · `{ file: { path, startLine?, endLine? } }` · `{ glob: { pattern, limit? } }` · `{ tool: { name, arguments? } }`。工具输出**不进上下文**,全文落盘、路径回传为 `material.spillPath`;`tool` 不能与 `file`/`glob` 同时给,插件自身工具会被拒。
114
+ **材料形态**:内联文本 · `{ file: { path, startLine?, endLine? } }` · `{ glob: { pattern, limit? } }` · `{ images: ["a.png"] }`(或 `{ text, images }`)· `{ tool: { name, arguments? } }`。工具输出**不进上下文**,全文落盘、路径回传为 `material.spillPath`;`text`/`file`/`glob` 可与 `images` 组合,`tool` 不能与其它形态组合,插件自身工具会被拒。
115
+
116
+ **图片**:工作区内 PNG/JPEG/WebP 路径(1–8 张;单张 ≤16 MiB、总量 ≤24 MiB base64 前、请求体 ≤32 MiB)。插件读盘、按 magic 校验并 base64 编码,**图片字节不进你的上下文**。图片请求需要视觉模型,且只发一次——`depth` 被忽略(不升档/不分批/不扫描)。输出里会带 `material.images`(`count`/`bytes`/`encodedBytes`/`formats`)。
109
117
 
110
118
  **读结果**:`results[]`(`id`/`type`/`value`/`probabilities?`/`confidence?`)· `coverage`(`requested`/`answered`/`batches`/`truncated`/`truncatedBy`/`failed`/`aborted`)· `cost`(`model`/`inputTokens`/`bodyBytes`/`wallMs`/`escalated`/`attempts`)· `notes`(首行是选档结论)。
111
119
 
@@ -232,4 +240,4 @@ MIT —— 见 [LICENSE](./LICENSE)。
232
240
 
233
241
  ## 无兼容层
234
242
 
235
- 这是一次性结构升级,不是版本过渡。旧行 id(`decision-tools-tool`)与旧配置键(`decisionEnabled` / `monitorEnabled`)不再被接受;使用上面的四行与三张 Config 表。契约见 [docs/architecture-current.md](./docs/architecture-current.md),用例见 [docs/use-cases.md](./docs/use-cases.md)。
243
+ 这是一次性结构升级,不是版本过渡。旧行 id(`decision-tools-tool`)与旧配置键(`decisionEnabled` / `monitorEnabled`)不再被接受;使用上面的四行与三张 Config 表。契约见 [docs/architecture.md](./docs/architecture.md),用例见 [docs/use-cases.md](./docs/use-cases.md)。
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: decision-usage
3
- description: Use for probability judgements on local material — `decision` (routing, classify/filter/rank, relevance, yes-no, 1–N scoring; inline/file/glob/tool input) and `monitor` (wait for a background job, file, command output or job event; polling, not a scheduler). Both return probabilities, not prose.
3
+ description: Use for probability judgements on local material — `decision` (routing, classify/filter/rank, relevance, yes-no, 1–N scoring; inline/file/glob/tool and PNG/JPEG/WebP image input) and `monitor` (wait for a background job, file, command output or job event; polling, not a scheduler). Both return probabilities, not prose.
4
4
  ---
5
5
 
6
6
  # Using the local decision model (`decision`) and `monitor`
@@ -20,20 +20,21 @@ Each `item` is the complete short string or object to judge; the plugin never in
20
20
 
21
21
  Other parameters: `depth`, `model` (see §3). Invalid calls fail with a stable `kind` (`mode_invalid` for mode/count/shape problems, `invalid_request` for field-level ones, `material_*`/`source_*`/`upstream_*` for the rest) — read the message, it lists the legal values.
22
22
 
23
- ## 2. `material`: inline, file, glob, or tool
23
+ ## 2. `material`: inline, file, glob, images, or tool
24
24
 
25
25
  - a string (inline material);
26
26
  - `{ file: { path, startLine?, endLine? } }` — `path` is workspace-root-relative (absolute, `..`, escaping symlinks refused); ranges are 1-based inclusive;
27
27
  - `{ glob: { pattern, limit? } }`;
28
- - `{ tool: { name, arguments? } }` — judge a host tool's **raw output**: it never enters your context, full text at `material.spillPath`; not with `file`/`glob` (`material_conflict`); the plugin's own tools are refused (`upstream_self_recursion`).
28
+ - `{ tool: { name, arguments? } }` — judge a host tool's **raw output**: it never enters your context, full text at `material.spillPath`; not with any other form (`material_conflict`); the plugin's own tools are refused (`upstream_self_recursion`).
29
+ - `{ images: ["a.png", "b.jpg"] }` — 1–8 workspace-relative PNG/JPEG/WebP paths, read and base64-encoded by the plugin; no image bytes enter your context. Combine with text via `{ text, images }` or `{ file/glob, images }`. **Requires a vision model**: pass `model: "clef-flash:latest"` (or `clef`), or set the decision row `modelOverride`; `tev1`/`nimble` return `does not support vision`. Per-image ≤16 MiB, total raw ≤24 MiB, request body ≤32 MiB. Image calls are a **single request**: no escalation, batching or scanning; `depth` is ignored.
29
30
 
30
- A file over the cap **fails**; a glob over the total cap is **truncated** (never complete). `material` reports the source, byte counts and `truncated`; `coverage.truncated`/`truncatedBy` mirror it.
31
+ A file over the cap **fails**; a glob over the total cap is **truncated** (never complete). `material` reports the source, byte counts and `truncated`; image returns add `material.images` (`count`/`bytes`/`encodedBytes`/`formats`); `coverage.truncated`/`truncatedBy` mirror text truncation.
31
32
 
32
33
  ## 3. `depth`: how much work the plugin may do
33
34
 
34
35
  - `quick` — default model only; over budget it **fails**. `normal` (default) — smallest fitting model, escalates on overflow. Batching never changes `results[]`: one entry per item. `deep` — starts bigger, may chunk the material.
35
36
 
36
- An explicit `model` disables capacity choice/escalation. Escalation switches to a different base model, so thresholds calibrated for one may not carry over (`cost.escalated`). Any Ollama model answering System One works: `model` pins a call, `defaultModel` changes the session default, `ladder` controls escalation, `node scripts/list-models.mjs` lists installed models.
37
+ An explicit `model` disables capacity choice/escalation. Escalation switches to a different base model, so thresholds calibrated for one may not carry over (`cost.escalated`). Any Ollama model answering System One works: `model` pins a call, `defaultModel` changes the session default, `ladder` controls escalation, `node scripts/list-models.mjs` lists installed models. For images, `model`/`modelOverride` must name a vision model (`clef`/`clef-flash`); `depth` is ignored and the call is sent once.
37
38
 
38
39
  ## 4. Read `results`, `coverage`, `cost`, `notes`
39
40
 
@@ -1,7 +1,7 @@
1
1
  # dsh-decision-tools — 现状盘点(架构 / 功能 / 接口)
2
2
 
3
- > 快照时点:代码 HEAD 2026-10-19(monitor 流式/通知/EP 收口后)· 套件 **455 例**(455 pass / 0 skip / 0 fail) · 工作树干净(本轮文档提交在其上)。已过一轮三人交叉对抗复审(task-12/13/14)与 Lead 终检、四轮修复(`3475758` / `588015d` / `5dba57d`+`2540e30` / `a0ad872`);其后新增 Monitor 与上游委托管道(`b1485e6`→`fcc1d86`),修掉一条真机 P0(`7a3c104`,见 §6 #22),并由 task-31 给 `decision` 补了同类宿主校验守卫(`b55b942`),本文已回填。
4
- > 本文描述 **v3 + 四行/GUI 现状**(as-is):`decision` 工具面为 material/rubric/items/questions/type/criteria/depth/model 八键,输出统一五键;同时注册第二个模型可见工具 `monitor`。
3
+ > 快照时点:代码 HEAD 2026-10-19(monitor 流式/通知/EP 收口后)· 套件 **460 例**(460 pass / 0 skip / 0 fail) · 工作树干净(本轮文档提交在其上)。已过一轮三人交叉对抗复审(task-12/13/14)与 Lead 终检、四轮修复(`3475758` / `588015d` / `5dba57d`+`2540e30` / `a0ad872`);其后新增 Monitor 与上游委托管道(`b1485e6`→`fcc1d86`),修掉一条真机 P0(`7a3c104`,见 §6 #22),并由 task-31 给 `decision` 补了同类宿主校验守卫(`b55b942`),本文已回填。
4
+ > 本文描述 **v3 + 四行/GUI 现状**(as-is):`decision` 工具面为 material/rubric/items/questions/type/criteria/depth/model 八键(`material` 内含 text/file/glob/images/tool),输出统一五键;同时注册第二个模型可见工具 `monitor`。
5
5
 
6
6
  ## 0. 一句话
7
7
 
@@ -24,30 +24,30 @@
24
24
  | `decision-tools-skill` | `dsh-decision-tools/skill`(`lib/skill.js`) | `['skills']` | 注册随包 skill `decision-usage` |
25
25
 
26
26
  - 无运行时依赖;peer 依赖 `dsh-tools` / `dsh-skill` / `cordis` / `schemastery`(`package.json`)。
27
- - 打包白名单:`lib/**/*.js`、`assets/**`、`cordis.patch.yml`、两份 README、`LICENSE`、`THIRD_PARTY_NOTICES`;`npm pack --dry-run` 实测 **32 files**(大小以实跑为准;`docs/`、`test/`、`smoke/` 在 git 里被跟踪但**不入包**)。
27
+ - 打包白名单:`lib/**/*.js`、`assets/**`、`docs/*.md`、`scripts/*.mjs`、`cordis.patch.yml`、两份 README、`CHANGELOG.md`、`LICENSE`、`THIRD_PARTY_NOTICES`;`npm pack --dry-run` 实测 **39 files**(大小以实跑为准;`docs/superpowers/`、`test/`、`smoke/`、`.temp/` 在 git 里被跟踪但**不入包**)。
28
28
  - 运行态通过 profile 的 `link:` symlink 指向工作区 ⇒ 磁盘代码即运行代码,**改码后要重载宿主**才生效。
29
29
 
30
- ### 1.2 模块图(25 个模块 / 8480 行,`wc -l lib/*.js` 实测于 2026-10-19 代码态)
30
+ ### 1.2 模块图(25 个模块 / 8687 行,`wc -l lib/*.js` 实测于 2026-10-19 代码态)
31
31
 
32
32
  | 模块 | 行 | 职责 |
33
33
  |---|---|---|
34
- | `tool.js` | 1330 | 装配与编排中枢:decision/monitor 两条工具行共用的引擎(参数解析、分批 / depth 三档 / 内部扫描、输出五键与渲染接线);source reader 由两条行各自创建 |
34
+ | `tool.js` | 1457 | 装配与编排中枢:decision/monitor 两条工具行共用的引擎(参数解析、分批 / depth 三档 / 内部扫描、输出五键与渲染接线);source reader 由两条行各自创建 |
35
35
  | `monitor.js` | 1232 | Monitor 内核:五类采样源适配、探测循环、按探测结果计数、前台 deadline/取消、后台作业注册与进度 |
36
- | `validate.js` | 934 | 校验层:LIMITS / MONITOR_LIMITS、两模式互斥、material 归一(含 file/glob 字符串简写)、depth 解析、逐题校验、criteria 归一(choice string[] / score {min,max})、analyzeBody、validateMonitorArgs / validateUpstream |
36
+ | `validate.js` | 986 | 校验层:LIMITS / MONITOR_LIMITS、两模式互斥、material 归一(text/file/glob/images,含字符串简写)、depth 解析、逐题校验、criteria 归一(choice string[] / score {min,max})、analyzeBody、validateMonitorArgs / validateUpstream |
37
37
  | `pipe.js` | 525 | 上游委托管道:runUpstream(同时带 agent 与 parent)、原始输出提取、无条件转储 spillWrite、白名单读取器、委托深度上限 |
38
38
  | `monitor-tool.js` | 648 | monitor 的模型可见面:紧凑方言参数契约、全函数 render(返回内容块数组)、注册 |
39
- | `source.js` | 361 | 引用式材料:描述符校验、行范围切片、chunk 分块、glob 匹配器 |
39
+ | `source.js` | 376 | 引用式材料:描述符校验、行范围切片、chunk 分块、glob 匹配器 |
40
40
  | `client.js` | 352 | HTTP 层:callSystemOne、错误分类(含溢出与 fetch blocked port 分支)、SystemOneError |
41
41
  | `escalate.js` | 328 | 阶梯、选档、单飞、容量分派 selectByCapacity、终态 describeExhausted |
42
42
  | `render.js` | 193 | 把结果渲染成给模型看的文本(零 import 的纯函数) |
43
43
  | `notify.js` | 209 | 通知策略:合并窗、限频、唤醒预算、投递(绝不调用 agent.cancel) |
44
- | `parameters.js` | 183 | 模型可见参数契约(八键,含 material 简写分支)+ 常驻 TOOL_DESCRIPTION |
44
+ | `parameters.js` | 191 | 模型可见参数契约(八键,含 material 简写与 images 分支)+ 常驻 TOOL_DESCRIPTION |
45
45
  | `ollama.js` | 198 | 模型发现(Ollama /api/tags 或 OpenAI /v1/models + /api/show):名称/上下文长度/参数量;支持 modelsPath 与 Bearer apiKey |
46
46
  | `scan.js` | 151 | 分块扫描编排、MAX_CONCURRENCY、聚合 aggregateNoul |
47
47
  | `event-log.js` | 158 | monitor 的 events 源生产者:定长环形缓冲 + 从 ctx.jobs 事件流映射 job.<status>(只读不写、故障隔离) |
48
48
  | `density.js` | 88 | 语言密度层:CJK 占比 → 2.32 / 3.33 B per token,softBytesFor(窗口 × 0.6 × 密度) |
49
49
  | `skill.js` | 88 | 注册随包 skill(provider、frontmatter 解析、资产定位) |
50
- | `wire.js` | 76 | 线格式唯一产地:WIRE_TYPES、toWireRequest(判据写在材料之后) |
50
+ | `wire.js` | 81 | 线格式唯一产地:WIRE_TYPES、toWireRequest(判据写在材料之后) |
51
51
  | `plan.js` | 73 | 决策表:planAction(send/scan/escalate/fail)—— depth=deep 分块的唯一入口 |
52
52
  | `deliver.js` | 43 | 通知的最后一跳:deliverNotice(agents, mode, payload, info)(followup/inject 二分、绝不 cancel、失败静默) |
53
53
  | `index.js` | 75 | 核心行入口:发布共享模型/运行时配置;裸包名承载 dsh.client 与浏览器半 |
@@ -62,9 +62,9 @@
62
62
 
63
63
  1. **入口**:`execute(args, exec)`;先 `ctx.skills.get('decision-usage')`,拿不到 ⇒ 抛 `kind=skill_missing` **拒绝服务**。
64
64
  2. **模式与参数校验**:`assertModeExclusive` —— `items`(批量)与 `questions`(异质)**恰有其一**,同给即 `mode_conflict`、都不给即 `mode_missing`;`resolveDepth` 解析三档(缺省 `normal`);逐题校验(题名唯一、题型合法、选项/档位按题型各自的 LIMITS)。
65
- 3. **材料物化**:`normalizeMaterial` 归一**三形态**(内联字符串 / `file` / `glob`);`tool` 由 `execute` 走 `resolveUpstreamMaterial` 旁路(不经 `normalizeMaterial`,故 `{tool,file}` 这类双给由 `execute` 显式报 `material_conflict`)。三种由 `resolveMaterial` 经 `source.js` 读盘(三道闸:描述符校验 / `ctx.fs.contains(root)` / `readBytes` 上限):**单文件超限报错**;**glob 总量超限截断**并报 `coverage.truncated`/`truncatedBy`。第四种 `material:{tool:{name,arguments?}}` 走 `resolveUpstreamMaterial`:在**插件内**委托执行上游工具(`ctx.tools.execute`,**必须同时带 `agent` 与 `parent`**)、取**原始输出**、**无条件**转储到自建目录,并把 `material.spillPath`/`bytes`/`fullBytes`/`truncated`(/`exitCode`) 回传 —— **原始输出不被吞掉**;禁自递归(名单含生效的 `toolName` 与 `monitorToolName`)。材料一律**不进模型上下文**。
65
+ 3. **材料物化**:`normalizeMaterial` 归一 `text` / `file` / `glob` / `images`(`text`/`file`/`glob` 可与 `images` 组合);`tool` 由 `execute` 走 `resolveUpstreamMaterial` 旁路(不经 `normalizeMaterial`,故 `{tool,...}` 双给由 `execute` 显式报 `material_conflict`)。`file`/`glob` 由 `resolveMaterial` 经 `source.js` 读盘(三道闸:描述符校验 / `ctx.fs.contains(root)` / `readBytes` 上限):**单文件超限报错**;**glob 总量超限截断**并报 `coverage.truncated`/`truncatedBy`。`images` 由 `tool.js` 的 image reader 逐张读盘、按 magic 识别 PNG/JPEG/WebP、base64 编码;单张 ≤16 MiB、总量 ≤24 MiB(base64 前)。`material:{tool:{...}}` 走 `resolveUpstreamMaterial`:在**插件内**委托执行上游工具(`ctx.tools.execute`,**必须同时带 `agent` 与 `parent`**)、取**原始输出**、**无条件**转储到自建目录,并把 `material.spillPath`/`bytes`/`fullBytes`/`truncated`(/`exitCode`) 回传 —— **原始输出不被吞掉**;禁自递归(名单含生效的 `toolName` 与 `monitorToolName`)。材料一律**不进模型上下文**。
66
66
  4. **模型选择**:`selectByCapacity` —— 显式 `model` 优先 → 估算字节(`softBytesFor`,按窗口与语言密度)≤ 默认档阈值走默认档 → 否则取阶梯里容量足够的最小档 → 都不够取最大档 → 阶梯空/容量未知才用粘滞兜底。选档结论以 `notes` 首行对外可见。
67
- 5. **线格式组装**:`wire.js` 的 `toWireRequest` 把 `material + rubric + items/questions + type + criteria + model + keepAlive` 组装成 `{model, state, questions}`:**`state` = 材料 + 两换行 + 判据块**(判据只出现一次、位于材料之后),`questions` 为对象映射(键为题名或 1-based 序号,值不含 name)。
67
+ 5. **线格式组装**:`wire.js` 的 `toWireRequest` 把 `material + rubric + items/questions + type + criteria + model + keepAlive + images` 组装成 `{model, state, questions, images?}`:**`state` = 材料 + 两换行 + 判据块**(判据只出现一次、位于材料之后;图片-only 时 state 可为空串),`questions` 为对象映射(键为题名或 1-based 序号,值不含 name),`images` 仅在非空时出现。
68
68
  6. **请求与提升**:单飞闸 `createSingleFlight`(互斥,不共享结果);命中 `context_overflow` 且未显式指定模型 ⇒ 沿阶梯逐档重试(`escalationMaxAttempts`:额外升档次数,不含首次;默认 3,deep 再 +2);**只有容量类错误才升档**,**只有成功才写粘滞**;用尽时抛统一终态 `escalation_exhausted`(附 `attempts`)。
69
69
  7. **分批与内部扫描**(按 `depth`):`quick` 只用默认档、不升档不分批(超预算报错);`normal` 允许按 body 预算**分批**(只改请求次数,不改返回结构);`deep` 额外允许**内部分块扫描**(`planAction` 定;失败隔离、可中断、有 deadline)。调用方看到的永远是 `results[]`,分批/分块是内部实现。
70
70
  8. **输出与渲染**:写**五键**结果(`results` / `coverage` / `material` / `cost` / `notes`),`render` 生成模型看到的文本(**只呈现概率、不呈现阈值化判定**;阈值比较只在 `tool.js` 一处);**首次使用**把 skill 正文一并交付。
@@ -76,7 +76,7 @@
76
76
  - **两模式互斥**(取代 v2 的「state 与 stateSource 恰有其一」);`results` 与批次解耦;结果五键的 own value 一律是无损 JSON(宿主会强校验)。
77
77
  - **工具返回值必须与自己的 `output.schema` 逐键一致(宿主运行时硬校验)**:宿主 `createSuccessResult` 会 `validateJsonSchemaValue(output.schema, value, 'value')`,**有 violation 就 `throw ToolOutputError`** —— 顶层 `additionalProperties:false` 下**少声明一个真会返回的键**就等于**每次成功都失败**。⚠️ **本地测试直呼 `definition.execute` 会绕过这条 dispatch 校验**,所以「返回值键集 == schema 键集」必须由守卫钉住,不能靠「套件全绿」判断。2026-10-17 的真实事故与修法:`monitor` 前台分支返回 `probability` 而 `OUTPUT_SCHEMA` 未声明它 ⇒ 真机每次前台成功返回都会被判失败(当时 343/343 全绿也看不见);`7a3c104` **补 schema 而不删字段**(render 一直在打印它,且它是与 `hitThreshold` 直接比较的第一性量),并在 monitor 侧加「用宿主 `validateJsonSchemaValue` 钉住两边键集」的守卫;decision 侧的同类守卫随 `b55b942`(task-31)落地。**边界**:真机 `createSuccessResult` 的**整条 dispatch 尚未跑过**(只跑了同一校验函数、同一入参)⇒ 「修后真机前台可用」是**推断**,真机验收归 task-26。
78
78
  - **Monitor 只记录、不阻断、不持久化**:计数只在内存(每次运行从 0 起算,故**没有重置通道**),`at`/`atIso`/`firstAt`/`lastAt` 毫秒只作记录、不参与命中判定;命中只**通知**,插件绝不 kill/cancel/retry/派发。被设计性删除的参数(`minGapMs` / `persistCount` / `resetCount`)**显式报错**而不是静默忽略 —— 静默忽略会让调用方以为冷却生效了。
79
- - 安全边界不变量:工作区内、读取上限、「单文件报错 / glob 截断」两种语义不可混同、截断的材料不得当完整材料用。
79
+ - 安全边界不变量:工作区内、读取上限、「单文件报错 / glob 截断」两种语义不可混同、截断的材料不得当完整材料用。图片另有边界:仅 PNG/JPEG/WebP(magic 判据)、1–8 张、单张 ≤16 MiB、总量 ≤24 MiB(base64 前)、整包 ≤32 MiB;图片请求**单次发出**,不升档/不分批/不扫描,`depth` 被忽略;`model` 或 decision 行 `modelOverride` 必须指向 vision 模型。
80
80
 
81
81
  ## 2. 功能清单
82
82
 
@@ -104,7 +104,7 @@
104
104
 
105
105
  | 参数 | 必填 | 形状 | 说明 |
106
106
  |---|---|---|---|
107
- | `material` | **是** | `string` 或 `{file:{path,startLine?,endLine?}}` 或 `{glob:{pattern,limit?}}` 或 `{tool:{name,arguments?}}` | 共享材料;后三种是引用式形态,**不进上下文**;`tool` 见 §3.6 |
107
+ | `material` | **是** | `string` 或 `{text,images}` 或 `{file:{path,startLine?,endLine?}}` 或 `{glob:{pattern,limit?}}` 或 `{images:["a.png"]}` 或 `{tool:{name,arguments?}}` | 共享材料;引用式形态**不进上下文**;`text`/`file`/`glob` 可与 `images` 组合;图片需视觉模型,且禁用升档/分批/扫描 |
108
108
  | `rubric` | 批量模式必填 | `string` | 共享判据,**只写一次**;wire 层放在材料之后 |
109
109
  | `items` | 批量模式必填 | 数组(≤64) | 每项是**自带完整待判对象**的短文本或标识;schema 允许任意 JSON,非字符串项**原样透传**给上游 |
110
110
  | `questions` | 异质模式必填 | `[{name,type,instructions,criteria?}]`(1–64) | 与 `items` **互斥**;每题自己的判据/选项 |
@@ -121,7 +121,7 @@ JSON Schema 的 `required` **只有 `material`**;模式内部必填由校验
121
121
  |---|---|
122
122
  | `results` | `[{id,type,value,probabilities?,confidence?}]`:`noul` 的 `value` 为布尔(上游不回 `confidence`),`choice`/`score` 为所选标签 |
123
123
  | `coverage` | `{requested,answered,batches,truncated,truncatedBy?,failed,aborted}`。`truncated` = **材料自身截断(glob 总量超 `maxSourceBytes`)或扫描截断**;`truncatedBy` 取值 `material` / `chunks` / `totalBytes` / `deadline`(未截断为 null);`aborted` = 调用方取消(含最后一块因取消失败);**`batches` 只数单元级分批、不含 deep 的内部分块**(真机实测:12 块时 `batches` 仍为 1 ⇒ 分块只体现在 `notes` 与 `cost`,不要拿 `batches` 当分块信号);未答项在 `results` 里以 `value:null` **占位**(保持 `results.length === requested`) |
124
- | `material` | `{source,bytes,descriptor?}`(`source` ∈ `inline`/`file`/`glob`);**上游委托**时为 `{source:'tool',upstreamTool,bytes,fullBytes,truncated,spillPath,exitCode?}`(`exitCode` 仅进程型工具) |
124
+ | `material` | `{source,bytes,descriptor?}`(`source` ∈ `inline`/`file`/`glob`,带图时另有 `images:{count,bytes,encodedBytes,formats}`);**上游委托**时为 `{source:'tool',upstreamTool,bytes,fullBytes,truncated,spillPath,exitCode?}`(`exitCode` 仅进程型工具) |
125
125
  | `cost` | `{model,inputTokens,bodyBytes,wallMs,escalated,attempts[]}`。`inputTokens`/`bodyBytes` = 本次调用**实际发出的全部请求**之和(含分批、deep 内部分块、失败尝试);`attempts` 只记**选档/升档**尝试(`{model,failureKind}`,成功项 `failureKind=null`),同档内的分批/分块不逐条记 |
126
126
  | `notes` | `string[]`:**首行是选档结论**(模型选择:<model>(<原因>)),其后是体积归因、低置信点名、扫描提示 |
127
127
 
@@ -180,9 +180,9 @@ JSON Schema 的 `required` **只有 `material`**;模式内部必填由校验
180
180
 
181
181
  ### 3.5 skill 接口
182
182
 
183
- `{name:'decision-usage', provider:'dsh-decision-tools', source:'bundled', rank:600, invocation:{modelInvocable:true, userInvocable:true}, resourceBase:{kind:'directory', path:<assets 目录>}}`;正文来自 `assets/decision-usage/SKILL.md`(**7663 B**,frontmatter 必须含 `description`(**可路由性硬约束**:两个工具名与主要触发词必须落在**前 200 字符**内 —— 技能目录只展示这段前缀,超出即被截断;长度 ≤400 字符);正文覆盖**两个工具的全部功能**:`decision` 的八键/四形态/三档 depth/五键读法/confidence/错误 kind,`monitor` 的 17 个参数、五类 source(含 `command` 的 argv 规则与 `events` 的全词表)、空源短路、后台 + `job_output`、通知三件套与输出字段)。
183
+ `{name:'decision-usage', provider:'dsh-decision-tools', source:'bundled', rank:600, invocation:{modelInvocable:true, userInvocable:true}, resourceBase:{kind:'directory', path:<assets 目录>}}`;正文来自 `assets/decision-usage/SKILL.md`(**8475 B**,frontmatter 必须含 `description`(**可路由性硬约束**:两个工具名与主要触发词必须落在**前 200 字符**内 —— 技能目录只展示这段前缀,超出即被截断;长度 ≤400 字符);正文覆盖**两个工具的全部功能**:`decision` 的八键/五种 material 形态(含 images)/三档 depth/五键读法/confidence/错误 kind,`monitor` 的 17 个参数、五类 source(含 `command` 的 argv 规则与 `events` 的全词表)、空源短路、后台 + `job_output`、通知三件套与输出字段)。
184
184
 
185
- **裁定变更(2026-10-18,用户指令):Skill 必须把功能写全。** 原 2026-10-17 裁定「不在 skill 里增加 Monitor 用法段」被**取代**:skill 是模型了解本插件能力的唯一按需入口,遗漏功能等于能力未交付。现在正文同时覆盖 `decision` 与 `monitor`,预算上限由 4000 B 提到 **8000 B**(按需加载,不进每轮注入)。约束只剩一条:**参数名必须与工具面同步**(守卫 `test/parameters.test.mjs` 逐个比对 `PARAMETERS` 的键、五类 `source.kind` 与 `EVENT_TYPES` 全词表)。
185
+ **裁定变更(2026-10-18,用户指令):Skill 必须把功能写全。** 原 2026-10-17 裁定「不在 skill 里增加 Monitor 用法段」被**取代**:skill 是模型了解本插件能力的唯一按需入口,遗漏功能等于能力未交付。现在正文同时覆盖 `decision` 与 `monitor`,预算上限由 4000 B 提到 **8000 B**,2026-10-19 因 image material 说明再提到 **9000 B**(按需加载,不进每轮注入)。约束只剩一条:**参数名必须与工具面同步**(守卫 `test/parameters.test.mjs` 逐个比对 `PARAMETERS` 的键、五类 `source.kind` 与 `EVENT_TYPES` 全词表)。
186
186
 
187
187
  ### 3.6 monitor 工具面(第二个模型可见工具)
188
188
 
@@ -240,7 +240,7 @@ JSON Schema 的 `required` **只有 `material`**;模式内部必填由校验
240
240
 
241
241
  ### 3.8 常量与阈值(`LIMITS` 及独立的 Monitor/管道常量)
242
242
 
243
- `maxQuestions:64` · `maxItems:64` · `minOptions:2` · `maxChoiceOptions:255` · `maxScoreLevels:10` · `maxBodyBytes:65536` · `maxSourceBytes:61440` · `scanMaxChunks:32` · `scanMaxTotalBytes:4194304` · `scanDeadlineMs:180000` · `scanMaxAdaptRounds:2` · `softBodyBytes:4096` · `minConfidence:0.25`
243
+ `maxQuestions:64` · `maxItems:64` · `minOptions:2` · `maxChoiceOptions:255` · `maxScoreLevels:10` · `maxBodyBytes:65536` · `maxImageBodyBytes:33554432` · `maxImages:8` · `maxImageBytes:16777216` · `maxTotalImageBytes:25165824` · `maxSourceBytes:61440` · `scanMaxChunks:32` · `scanMaxTotalBytes:4194304` · `scanDeadlineMs:180000` · `scanMaxAdaptRounds:2` · `softBodyBytes:4096` · `minConfidence:0.25`
244
244
 
245
245
  另有**独立常量对象**(与 `LIMITS` 分开;数值只有一处来源,`Config` 里只放覆盖点、不复制数字):
246
246
 
@@ -264,16 +264,16 @@ JSON Schema 的 `required` **只有 `material`**;模式内部必填由校验
264
264
 
265
265
  | 工具 | description(≤1900) | 参数 schema(≤3600) | 输出 schema |
266
266
  |---|---|---|---|
267
- | `decision` | 1809 B | 3487 B(归一后投影;守卫口径的紧凑串 3465 B) | 841 B |
268
- | `monitor` | 1830 B | 2726 B(紧凑串 2692 B) | 1995 B(P0 修复后;修前 1878 B) |
267
+ | `decision` | 1863 B | 3803 B(归一后投影;含 image material) | 841 B |
268
+ | `monitor` | 1833 B | 3167 B(归一后投影) | 1995 B(P0 修复后;修前 1878 B) |
269
269
 
270
- 合计 **13027 B ≈ 3912 token/轮**(英文 3.33 B/token 折算);skill 正文 **6758 B(≤8000)**只在首次使用时到达(2026-10-18 用户要求功能写全后由 4000 提到 8000)。⇒ 常驻成本比 v2 末期(5653 B ≈ 1698 token/轮)**涨了**:省的是每次调用的请求体与计费,不是常驻。
270
+ 合计 **13502 B ≈ 4055 token/轮**(英文 3.33 B/token 折算);skill 正文 **8475 B(≤9000)**只在首次使用时到达(2026-10-18 用户要求功能写全后由 4000 提到 8000;2026-10-19 image material 说明后提到 9000)。⇒ 常驻成本比 v2 末期(5653 B ≈ 1698 token/轮)**涨了**:省的是每次调用的请求体与计费,不是常驻。
271
271
 
272
272
  ## 5. 测试与门禁
273
273
 
274
- - 套件 `node --test --experimental-test-isolation=none "test/*.test.mjs"` = **455 例**(2026-10-19 代码态实跑核对;455 pass / 0 skip / 0 fail)= 顶层 `test(` 声明 **431** 条(`grep -c '^test('` 逐文件相加)+ 循环内生成的表驱动用例 **24** 条;共 **32** 个测试文件(`ls test/*.test.mjs | wc -l` = 32);另有装置 `test/fake-ollama.mjs`(不匹配 `test/*.test.mjs` 这个 glob,不算例)。
274
+ - 套件 `node --test --experimental-test-isolation=none "test/*.test.mjs"` = **460 例**(2026-10-19 代码态实跑核对;460 pass / 0 skip / 0 fail)= 顶层 `test(` 声明 **436** 条(`grep -c '^test('` 逐文件相加)+ 循环内生成的表驱动用例 **24** 条;共 **32** 个测试文件(`ls test/*.test.mjs | wc -l` = 32);另有装置 `test/fake-ollama.mjs`(不匹配 `test/*.test.mjs` 这个 glob,不算例)。
275
275
  - **墙钟纪律(精确版)**:**不得用真实计时派生判定**(如按 deadline 计数、按耗时决定分支)。deep 的多轮重切预算用可注入时钟 + 纯函数 `scanRoundBudget` 断言(曾有一条用服务端忙等 60 ms 的用例在并行负载下抖红,已重写)。**已登记的例外**:`test/client.test.mjs` 用 `Date.now()` 做 `elapsed < 2000 ms` 的粗上限兜底、`test/escalate.test.mjs` 有一处 10 ms 等待用于让承诺沉降 —— 这两处不派生判定结果,保留并登记(见 §6 #21)。
276
- - 守卫:§4.9 判别性锚点、skill 锚点(十四条 v3 读法 + 词表负例)、README 配置表与 `Config` 投影一致(**29 键,按行计**;两份 README 的三张表键集与顺序 == 三条 `Config.dict`)、注入预算上限(`decision` description ≤1900 / 参数 schema ≤3600 / skill ≤8000;`monitor` description ≤1900 / 参数 schema ≤3600)、`COMPANION_SKILL === SKILL_NAME`、缺 skill 拒服务、首次交付 skill 正文;Monitor/管道新增:**零真实墙钟**(clock/sleep/decide/deliver 全注入)、通知三件套(合并 + 限频 + 唤醒预算)、`material.tool` 双形状对齐(模型面 `{name,arguments}` ↔ 校验层 `{tool,arguments}`)、禁自递归(名单含生效的 `toolName` 与 `monitorToolName`)、模型可见文案词表(v2 参数名负例,含 `stateSource`)、**output schema 与返回值键集一致**(monitor 侧 `7a3c104`、decision 侧 `b55b942`,两边都用宿主的 `valueSchemaSpecToJsonSchema` + `validateJsonSchemaValue` 钉住)。
276
+ - 守卫:§4.9 判别性锚点、skill 锚点(十四条 v3 读法 + 词表负例)、README 配置表与 `Config` 投影一致(**29 键,按行计**;两份 README 的三张表键集与顺序 == 三条 `Config.dict`)、注入预算上限(`decision` description ≤1900 / 参数 schema ≤4000(image material 计入)/ skill ≤9000;`monitor` description ≤1900 / 参数 schema ≤3600)、`COMPANION_SKILL === SKILL_NAME`、缺 skill 拒服务、首次交付 skill 正文;Monitor/管道新增:**零真实墙钟**(clock/sleep/decide/deliver 全注入)、通知三件套(合并 + 限频 + 唤醒预算)、`material.tool` 双形状对齐(模型面 `{name,arguments}` ↔ 校验层 `{tool,arguments}`)、禁自递归(名单含生效的 `toolName` 与 `monitorToolName`)、模型可见文案词表(v2 参数名负例,含 `stateSource`)、**output schema 与返回值键集一致**(monitor 侧 `7a3c104`、decision 侧 `b55b942`,两边都用宿主的 `valueSchemaSpecToJsonSchema` + `validateJsonSchemaValue` 钉住)。
277
277
  - 另有:`node --check lib/*.js`、CR 字节 = 0、变异自检(累计 30+ 定点变异全杀)、隔离实例真机冒烟。
278
278
  - **装置不变量**(`cee0baa` 之后):假服务永不交出 fetch 禁用端口;并发负载下错误分类不退化为 `unreachable`。
279
279
  - **冒烟必须真断言**(`fca5d2f`):`smoke/live.mjs` 必须按**工具的五键形状**组装 value 再渲染,并断言「有 answers / 有 `usage.input_tokens` / 渲染文本非空占位 / 无 `undefined|NaN`」,任一不成立即**非 0 退出**并打印拿到了什么。历史教训:该脚本曾把原始回包(`{model,answers,usage}`)当五键喂 `renderAnswers` ⇒ 永远打印 `(no results returned)` 且 exit 0(假绿,见 §6 #21)。
@@ -1,6 +1,6 @@
1
1
  # 阈值标定、粘滞优先级与运行时能力发现
2
2
 
3
- > 口径文档(2026-10-16)。依据:`docs/architecture-current.md` §4(成本与容量口径,含 §8 r6 真机数字)、
3
+ > 口径文档(2026-10-16)。依据:`docs/architecture.md` §4(成本与容量口径,含 §8 r6 真机数字)、
4
4
  > `docs/superpowers/plans/2026-10-16-v3-improvement-plan.md` Task 17、用户裁定 R1(显式 model 与成功升档都写粘滞)。
5
5
  > 本文只写**方法与口径**:任何阈值都必须在你自己的标注集与业务上重新标定,本文不给「通用阈值」。
6
6
 
@@ -54,7 +54,7 @@
54
54
  换模型必须重标。
55
55
  - 跨会话研究里唯一一次顺序诱发的标签翻转落在 `confidence = 0.357`(**高于** 0.25)⇒ 该门限
56
56
  **不是**安全保证,只是「把最差的那些点出来」的旗。
57
- - 出处:`docs/architecture-current.md` §4 与 `README.md` 的 `minConfidence` 段;这些数字属于**当时样本**,换模型/换业务要重测。
57
+ - 出处:`docs/architecture.md` §4 与 `README.md` 的 `minConfidence` 段;这些数字属于**当时样本**,换模型/换业务要重测。
58
58
 
59
59
  ## 3. 粘滞:三层优先级与失效条件
60
60
 
@@ -96,7 +96,7 @@
96
96
 
97
97
  - **口径注(官方文档自身不一致)**:官方决策指南写 `choice` **2–26 named options**,官方 API 参考写
98
98
  `SystemOneChoiceQuestion.criteria` 的 `maxProperties: 255` 并注明 *"The option limit depends on the model"*
99
- (抓取件与行号见 `docs/architecture-current.md` §4 的口径注)。⇒ **不得声称插件能本地探测题型上限**;
99
+ (抓取件与行号见 `docs/architecture.md` §4 的口径注)。⇒ **不得声称插件能本地探测题型上限**;
100
100
  也不要把 255 或 26 当跨版本 / 跨模型契约。
101
101
  - `score` 的 2–10 是与 `choice` **相互独立**的约束,不可共用一个常量。
102
102
  - 顺序固定为:**运行时能力 > 静态常量**。探测成功即用探测值;探测失败才回退,且回退值必须标注为快照。
package/docs/use-cases.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # `decision` 工具:使用场景(use cases)
2
2
 
3
- > 与 `docs/architecture-current.md` 配套:那篇讲**是什么**,这篇讲**什么场景该用、怎么调、成本与边界在哪**。
4
- > 事实基线:2026-10-19 代码态 · 套件 455/455 绿 · 本包注册**两个**模型可见工具:`decision`(八键:`material`/`rubric`/`items`/`questions`/`type`/`criteria`/`depth`/`model`)与 `monitor`(探测式等待原语,见 §7)。
3
+ > 与 `docs/architecture.md` 配套:那篇讲**是什么**,这篇讲**什么场景该用、怎么调、成本与边界在哪**。
4
+ > 事实基线:2026-10-19 代码态 · 套件 460/460 绿 · 本包注册**两个**模型可见工具:`decision`(八键:`material`/`rubric`/`items`/`questions`/`type`/`criteria`/`depth`/`model`)与 `monitor`(探测式等待原语,见 §7)。
5
5
 
6
6
  ## 0. 判定形状(决定它适配哪些场景)
7
7
 
8
8
  - 输入 = **一份共享材料** + **1–64 个有界问题**;输出 = 每题的概率 / 标签 / 分档。
9
- - **不产文字**、不做推理链、**本扩展不带图片输入**(上游 System One 支持 `images`,见 §5 口径注)。
9
+ - **不产文字**、不做推理链;**支持图片 material**(PNG/JPEG/WebP,需视觉模型 `clef` / `clef-flash`),见 §2 与 §5。
10
10
  - 适用前提三条:**候选集已知且有限**、**判据能写清**、**答案能用概率表达**。
11
11
  - **`monitor` 是另一个工具、不是本工具的档位**:它把同一份判定能力用在「对同一个问题反复问」上(等一个事件发生),见 §7。
12
12
 
@@ -20,7 +20,7 @@
20
20
  | 4 | **一材料多异质问题** | 一份合同问 5 个不同问题 · 一份报告问是否合规/是否含某字段/是否需复核 | 混合 | 材料=那一份;每题自己的 `instructions` | 2–10 | 手感尚可(本来就该每题一句) |
21
21
  | 5 | **长材料定位**(哪些块提到 X) | 跨长文或多文件找相关段 · 大文档初筛候选段 | `noul` | `material` 的 file/glob;材料装不下时 `depth: 'deep'` 允许内部按块扫描 | 1–8 | 分块只答**可定位**问题;材料过大时装不下就报错,不会自动分块 |
22
22
 
23
- ## 2. `material` 的四种形态
23
+ ## 2. `material` 的形态
24
24
 
25
25
  | 形态 | 何时用 | 注意 |
26
26
  |---|---|---|
@@ -28,8 +28,9 @@
28
28
  | `{ file: { path, startLine?, endLine? } }` | 材料在工作区文件里、**不想进自己的上下文** | `path` 必须**工作区相对**(绝对路径 / `..` / 逃逸符号链接一律拒);行范围 1-based 闭区间;单文件超上限**报错**,不截断 |
29
29
  | `{ glob: { pattern, limit? } }` | 一批文件要一起判 | `*` 不跨目录、`**` 任意深度;总量超上限**截断**,并在 `coverage.truncated` / `coverage.truncatedBy` 如实报告 —— **截断的材料不得当完整材料用** |
30
30
  | `{ tool: { name, arguments? } }` | 材料是**另一个工具的输出**:既不想把它拉进自己的上下文,又要判它 | 插件在**进程内**委托执行(**始终**带 `agent` 与 `parent`)、取**原始输出**、**无条件**转储,并把 `spillPath`/`bytes`/`fullBytes`/`truncated`(/`exitCode`) 回传到 `material` —— 原始输出**不被吞掉**。**禁自递归**(名单含生效的 `toolName` 与 `monitorToolName`)。缺 `agent` 会让作用域外工具名报 unknown tool;缺 `parent` 会让模型面 content 被截断 |
31
+ | `{ images: ["a.png", "b.jpg"] }` 或 `{ text: "...", images: [...] }` | 图片判定:截图是否含错误、UI 是否异常、票据字段、图表读数 | 图片由插件读盘并 base64 编码,**不进你的上下文**;仅支持 PNG/JPEG/WebP;1–8 张,单张 ≤16 MiB、总量 ≤24 MiB;需要视觉模型(`clef` / `clef-flash`),用 `model` 或 decision 行 `modelOverride` 指定。图片请求**不分批/不升档/不扫描**(32 MiB body 上限),`depth` 被忽略 |
31
32
 
32
- 四种形态由 `material` **一个参数**承载(取代旧版的 `state` + `stateSource`),且后三种引用式形态都由插件自己取料、**永不进你的上下文**。`material` 是**必填**。
33
+ 这些形态由 `material` **一个参数**承载(取代旧版的 `state` + `stateSource`),且引用式形态都由插件自己取料、**永不进你的上下文**。`material` 是**必填**。`text` / `file` / `glob` 可以与 `images` 组合;`tool` 不能与其它形态组合。
33
34
 
34
35
  ## 3. 场景 → 调用骨架
35
36
 
@@ -67,7 +68,19 @@ v3 形状 —— 判据只写一次,N 项各自只带自己的短对象:
67
68
  { name: '风险', type: 'score', instructions: '风险等级', criteria: [ '低', '中', '高' ] } ] }
68
69
  ```
69
70
 
70
- ### 3.3 长材料定位(深档 + `coverage` 读法)
71
+ ### 3.3 图片判定(vision)
72
+
73
+ ```jsonc
74
+ {
75
+ material: { images: ['screenshots/checkout-error.png'] },
76
+ model: 'clef-flash:latest',
77
+ questions: [ { name: 'has_error', type: 'noul', instructions: 'Does this screenshot show an error state?' } ]
78
+ }
79
+ ```
80
+
81
+ 也可以把文字上下文与图片一起给:`material: { text: 'Checkout fails after clicking Pay.', images: ['a.png'] }`。`state` 为空是合法的(图片-only),判据由 `questions` 的 `instructions` 或批量 `rubric` 提供。
82
+
83
+ ### 3.4 长材料定位(深档 + `coverage` 读法)
71
84
 
72
85
  ```jsonc
73
86
  {
@@ -95,14 +108,12 @@ v3 形状 —— 判据只写一次,N 项各自只带自己的短对象:
95
108
  | 需要**可读判据**作为证据存档 | tev1 系只回概率 | 生成式模型,或人写结论 |
96
109
  | 路由 / 放行的**最终判据**(fail-open/closed) | 概率不是证明;模型坏了会连带坏掉路由 | 确定性规则;本工具只做初筛 |
97
110
  | 对整篇长文问**整体**结论并交给深档内部分块 | 逐块最大 ≠ 文档级概率 | 先分块定位候选段(`depth: 'deep'`),再对候选段单发提问 |
98
- | 图片 / 视觉判定 | **当前 DSH 扩展不支持**(`decision` 工具面没有 `images` 参数) | 直接用 Ollama `/v1/systemone` 的 `images` 字段(本扩展未包),或另用视觉模型/工具 |
111
+ | 用 `tev1` / `nimble` 判图 | 这两个模型不支持 vision,服务端会回 400 `does not support vision` | 改用 `clef` / `clef-flash`,并显式给 `model` 或在 decision 行设置 `modelOverride` |
99
112
  | 把结果当**正确性证据** | 概率是提示不是证据 | 产物级证据 |
100
113
 
101
- > **口径注(图片,2026-10-16)**:**上游 System One 支持图片** —— `/v1/systemone` 请求体有 `images` 字段
102
- > (base64 编码的 PNG/JPEG/WebP,被该次请求的所有问题共享),**Clef / Clef Flash 可判图**
103
- > (官方决策指南原文:「Clef and Clef Flash read images alongside the state」)。本仓库的 `decision` 扩展
104
- > **没有把 `images` 暴露成工具参数**,所以上表记「不支持」——这是**本扩展的缺口,不是上游或 System One 的限制**。
105
- > 本地请求体限制也因此分两档:文本与 schema(不含图)≤64 KiB,整包(含 base64 与 JSON)≤32 MiB。
114
+ > **图片口径(2026-10-19)**:`material.images` 已暴露;插件只接受工作区内的 PNG/JPEG/WebP 路径,按 magic bytes 识别后 base64 上送。
115
+ > 官方决策指南确认 **Clef / Clef Flash 可读图**(「Clef and Clef Flash read images alongside the state」);`tev1` / `nimble` 不支持 vision。
116
+ > 本地请求体限制分两档:**文本 ≤64 KiB**,**含图整包 ≤32 MiB**。图片请求不分批/不升档/不扫描,`depth` 被忽略;一次最多 8 张、单张 ≤16 MiB、总量 ≤24 MiB(base64 前)。
106
117
 
107
118
  ## 6. 场景 × API 适配度(v3 的验收标准)
108
119
 
package/lib/parameters.js CHANGED
@@ -36,7 +36,7 @@ export const TOOL_DESCRIPTION = [
36
36
  ' choice/score) — one shared criterion, judged independently per item. Mixed: `material` +',
37
37
  ' `questions`. Giving both, or neither, is an error.',
38
38
  '- `material` is inline text or a reference that never enters your context (`file` / `glob`,',
39
- ' workspace-root-relative). Write `rubric` ONCE — the wire layer repeats it for the model.',
39
+ ' workspace-root-relative). Images: `{images:["a.png"]}` (vision model required). Write `rubric` ONCE — the wire layer repeats it for the model.',
40
40
  '- `depth`: normal (default) picks the smallest model that fits, escalates on overflow, splits into',
41
41
  ' batches. quick: default model only, no escalation, no batching (over budget fails). deep: starts',
42
42
  ' larger and may chunk the material internally.',
@@ -58,21 +58,29 @@ export const PARAMETERS = {
58
58
  material: {
59
59
  required: true,
60
60
  description:
61
- 'Shared material: inline text, a workspace reference, or a tool to run. References never enter your context.',
61
+ 'Shared material: text, text+images, a reference, or a tool. References never enter your context.',
62
62
  oneOf: [
63
63
  { type: 'string', description: 'Inline material text.' },
64
64
  {
65
65
  type: 'object',
66
66
  additionalProperties: false,
67
- description: 'Reference: `{file:"path"}` / `{glob:"pattern"}` shorthand, object forms, or `{tool:{name,arguments}}`.',
67
+ description: 'Reference: `{file}` / `{glob}` / `{images}`, or `{tool}`. text/file/glob may combine with images.',
68
68
  properties: {
69
+ text: {
70
+ type: 'string',
71
+ description: 'Inline text; combine with images only.',
72
+ },
73
+ images: {
74
+ type: 'json',
75
+ description: 'PNG/JPEG/WebP paths (1-8), read and encoded by the plugin. Needs a vision model: pass `model` or set row `modelOverride`.',
76
+ },
69
77
  file: {
70
78
  oneOf: [
71
- { type: 'string', description: 'Shorthand path.' },
79
+ { type: 'string', description: 'Shorthand text path.' },
72
80
  {
73
81
  type: 'object',
74
82
  additionalProperties: false,
75
- description: 'Workspace file; optional 1-based range.',
83
+ description: 'Text file with optional line range.',
76
84
  properties: {
77
85
  path: { type: 'string', required: true, description: 'Workspace-relative path.' },
78
86
  startLine: { type: 'number', description: '1-based first line.' },
@@ -83,11 +91,11 @@ export const PARAMETERS = {
83
91
  },
84
92
  glob: {
85
93
  oneOf: [
86
- { type: 'string', description: 'Shorthand pattern.' },
94
+ { type: 'string', description: 'Shorthand text pattern.' },
87
95
  {
88
96
  type: 'object',
89
97
  additionalProperties: false,
90
- description: 'Files by pattern; paths are prefixed.',
98
+ description: 'Text files by pattern.',
91
99
  properties: {
92
100
  pattern: { type: 'string', required: true, description: 'e.g. docs/*.md (`**` = any depth).' },
93
101
  limit: { type: 'number', description: 'Max files to read.' },
package/lib/source.js CHANGED
@@ -57,6 +57,21 @@ export function isUnsafePath(path) {
57
57
  return normalized.split('/').some((segment) => segment === '..');
58
58
  }
59
59
 
60
+ /** 上游 System One 支持的图片扩展名(大小写不敏感;magic 仍是最终判据)。 */
61
+ export const IMAGE_EXTENSIONS = Object.freeze(['png', 'jpg', 'jpeg', 'webp']);
62
+
63
+ /**
64
+ * 路径是否看起来是图片。**只用于文案与提前分流**:真正的格式判定在 tool.js
65
+ * 读盘后按 magic bytes 做(扩展名可以撒谎)。
66
+ * @param {unknown} path 路径
67
+ * @returns {boolean}
68
+ */
69
+ export function isImagePath(path) {
70
+ if (typeof path !== 'string') return false;
71
+ const match = /\.([A-Za-z0-9]+)$/.exec(path.trim());
72
+ return match !== null && IMAGE_EXTENSIONS.includes(match[1].toLowerCase());
73
+ }
74
+
60
75
  /**
61
76
  * 取一个可选的 ≥ 1 整数;未设返回 undefined,非法即报错。
62
77
  * @param {unknown} value 待校验值
package/lib/tool.js CHANGED
@@ -8,7 +8,7 @@ import { PARAMETERS, TOOL_DESCRIPTION } from './parameters.js';
8
8
  import { assertModeExclusive, resolveDepth, resolveMaterial, validateQuestions, assertBatchCriteria, analyzeBody, DecisionValidationError, LIMITS, MONITOR_LIMITS, validateUpstream } from './validate.js';
9
9
  import { renderAnswers } from './render.js';
10
10
  import { callSystemOne, classifyOverflow } from './client.js';
11
- import { DecisionSourceError, sliceByLines, validateSourceDescriptor, splitGlobPattern, matchGlobSegment } from './source.js';
11
+ import { DecisionSourceError, sliceByLines, validateSourceDescriptor, splitGlobPattern, matchGlobSegment, isImagePath, isUnsafePath } from './source.js';
12
12
  import {
13
13
  DEFAULT_LADDER, ESCALATION_MARGIN, createSingleFlight, describeExhausted, describeUnknownCapacity,
14
14
  selectByCapacity, selectEscalation,
@@ -248,6 +248,12 @@ function createSourceReader(ctx) {
248
248
 
249
249
  /** 读一个已确认在根内的文件(整份或行范围)。 */
250
250
  const readInside = async (path, target, extraction) => {
251
+ if (isImagePath(path)) {
252
+ throw new DecisionSourceError(
253
+ `material.file 是图片(${path});请改用 material.images: ["${path}"] 传入图片。`,
254
+ { kind: 'material_invalid' },
255
+ );
256
+ }
251
257
  let bytes;
252
258
  try {
253
259
  bytes = await ctx.fs.readBytes(target, options.signal, cap);
@@ -305,6 +311,8 @@ function createSourceReader(ctx) {
305
311
  { kind: 'source_outside_root' });
306
312
  }
307
313
  const collected = [];
314
+ /** glob 里被跳过的图片文件数(文本 reader 不读图;提示调用方改用 material.images)。 */
315
+ let skippedImages = 0;
308
316
  /** 已收文件的路径集合:`**` 的「吃零层」与「吃一层」两支会走到同一个文件,必须去重。 */
309
317
  const seen = new Set();
310
318
  /** **state 的最终字节数**(含 `===== path =====` 表头与块间分隔符)—— 与 cap 同一口径。 */
@@ -318,6 +326,7 @@ function createSourceReader(ctx) {
318
326
 
319
327
  const take = async (path, entry) => {
320
328
  if (truncated || seen.has(path)) return;
329
+ if (isImagePath(path)) { skippedImages += 1; return; }
321
330
  if (!ctx.fs.contains(rootTarget, entry.target)) return; // 根外命中项静默跳过
322
331
  if (seen.size >= limit) { truncated = true; remaining += 1; return; }
323
332
  const cost = overhead(path);
@@ -398,11 +407,84 @@ function createSourceReader(ctx) {
398
407
  bytes: Buffer.byteLength(state, 'utf8'),
399
408
  truncated,
400
409
  remaining,
410
+ skippedImages,
401
411
  },
402
412
  };
403
413
  };
404
414
  }
405
415
 
416
+ /**
417
+ * 按 magic bytes 识别上游支持的图片格式。扩展名可以撒谎,读盘后必须看文件头。
418
+ * @param {Uint8Array|Buffer} bytes 文件原始字节
419
+ * @returns {'image/png'|'image/jpeg'|'image/webp'|undefined}
420
+ */
421
+ export function sniffImageMime(bytes) {
422
+ const buf = Buffer.from(bytes);
423
+ if (buf.length >= 8
424
+ && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47
425
+ && buf[4] === 0x0d && buf[5] === 0x0a && buf[6] === 0x1a && buf[7] === 0x0a) return 'image/png';
426
+ if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return 'image/jpeg';
427
+ if (buf.length >= 12
428
+ && buf.toString('ascii', 0, 4) === 'RIFF'
429
+ && buf.toString('ascii', 8, 12) === 'WEBP') return 'image/webp';
430
+ return undefined;
431
+ }
432
+
433
+ /**
434
+ * 造图片读取器:工作区相对路径 → 三道闸(字符串安全 / containment / 字节上限)
435
+ * → magic 识别 → base64。**不接受 base64 直接透传**,避免图片先把模型上下文塞满。
436
+ * @param {any} ctx 宿主上下文(只需 `fs`)
437
+ * @returns {(path: string, options?: { signal?: AbortSignal, cwd?: string }) => Promise<{ path: string, mime: string, bytes: number, base64: string }>}
438
+ */
439
+ function createImageReader(ctx) {
440
+ const cap = LIMITS.maxImageBytes;
441
+ return async function readImage(path, options = {}) {
442
+ if (typeof path !== 'string' || path.trim() === '') {
443
+ throw new DecisionSourceError('material.images 的每一项必须是非空路径字符串。', { kind: 'material_invalid' });
444
+ }
445
+ if (isUnsafePath(path)) {
446
+ throw new DecisionSourceError(
447
+ `material.images 必须是工作区根内的相对路径(拒绝绝对路径与 .. 段):${path}`,
448
+ { kind: 'material_invalid' },
449
+ );
450
+ }
451
+ const anchored = options.cwd === undefined ? {} : { cwd: options.cwd };
452
+ const rootTarget = await ctx.fs.resolve('.', anchored);
453
+ const target = await ctx.fs.resolve(path, { ...anchored, signal: options.signal });
454
+ if (!ctx.fs.contains(rootTarget, target)) {
455
+ throw new DecisionSourceError(
456
+ `material.images 解析后落在工作区根之外,已拒绝:${path}`,
457
+ { kind: 'source_outside_root' },
458
+ );
459
+ }
460
+ let bytes;
461
+ try {
462
+ bytes = await ctx.fs.readBytes(target, options.signal, cap);
463
+ } catch (error) {
464
+ const message = String(error?.message ?? error);
465
+ if (/not found|ENOENT|NOT_FOUND|no such file/i.test(message)) {
466
+ throw new DecisionSourceError(`material.images 指向的文件不存在:${path}`, { kind: 'source_missing' });
467
+ }
468
+ if (/too large|TOO_LARGE/i.test(message)) {
469
+ throw new DecisionSourceError(
470
+ `material.images 单张上限 ${cap} 字节(超限不截断):${path}`,
471
+ { kind: 'source_too_large' },
472
+ );
473
+ }
474
+ throw new DecisionSourceError(`material.images 读不动:${path} —— ${message}`, { kind: 'invalid_request' });
475
+ }
476
+ const buffer = Buffer.from(bytes);
477
+ const mime = sniffImageMime(buffer);
478
+ if (mime === undefined) {
479
+ throw new DecisionSourceError(
480
+ `material.images 只支持 PNG / JPEG / WebP:${path}(文件头不是这三种格式)。`,
481
+ { kind: 'image_unsupported' },
482
+ );
483
+ }
484
+ return { path, mime, bytes: buffer.length, base64: buffer.toString('base64') };
485
+ };
486
+ }
487
+
406
488
  /**
407
489
  * `depth` 三档的语义(ADR-14 的表)。**分块扫描在 v3 是内部行为**:只有 `deep` 允许。
408
490
  *
@@ -519,6 +601,7 @@ export function resolveDecisionConfig(row = {}) {
519
601
  const scanAggregate = live(row.scanAggregate, undefined);
520
602
  return {
521
603
  baseUrl: shared.baseUrl,
604
+ modelOverride: typeof modelOverride === 'string' ? modelOverride.trim() : '',
522
605
  defaultModel: typeof modelOverride === 'string' && modelOverride.trim() !== '' ? modelOverride : shared.defaultModel,
523
606
  timeoutMs: shared.timeoutMs,
524
607
  keepAlive: shared.keepAlive,
@@ -605,6 +688,7 @@ function applyEngine(ctx, options = {}) {
605
688
  const sticky = { state: null };
606
689
  const singleFlight = createSingleFlight();
607
690
  const readSource = createSourceReader(ctx);
691
+ const readImage = createImageReader(ctx);
608
692
 
609
693
  /**
610
694
  * monitor 的采样读取器:**复用 decision 的同一个 reader**(消除第二份同语义实现),
@@ -739,12 +823,29 @@ function applyEngine(ctx, options = {}) {
739
823
  spillMaxBytes: effectiveSpillMaxBytes,
740
824
  selfToolNames: [toolName, monitorToolName],
741
825
  });
826
+ const imagePaths = Array.isArray(material.images) ? material.images : [];
827
+ const images = [];
828
+ let imageBytes = 0;
829
+ for (const path of imagePaths) {
830
+ const image = await readImage(path, { signal, cwd: sessionCwd(exec) });
831
+ imageBytes += image.bytes;
832
+ if (imageBytes > LIMITS.maxTotalImageBytes) {
833
+ throw new DecisionValidationError(
834
+ `material.images 原始总量 ${imageBytes} 字节,超过上限 ${LIMITS.maxTotalImageBytes} 字节(base64 编码前);`
835
+ + '可行动项:减少图片数量、压缩图片,或分多次调用。',
836
+ { kind: 'material_invalid' },
837
+ );
838
+ }
839
+ images.push(image);
840
+ }
841
+ const hasImages = images.length > 0;
842
+ const imageBase64Bytes = images.reduce((sum, image) => sum + image.base64.length, 0);
742
843
  // 空材料守卫(**只针对引用式材料**):glob 未命中、或被总量上限截断到一个文件都没收。
743
- // 内联材料是字符串,非空由 normalizeMaterial 把关。
744
- if (material.source !== undefined && material.text.trim() === '') {
844
+ // images-only 是合法材料,不在这里拒绝。
845
+ if (material.source !== undefined && material.text.trim() === '' && !hasImages) {
745
846
  throw new DecisionSourceError(
746
- 'material 没有读到任何内容(模式未命中,或已被总量上限截断)。'
747
- + '可行动项:放宽 pattern、改用 file + 行范围,或调大 limit。',
847
+ 'material 没有读到任何文本内容(模式未命中、被总量上限截断,或只命中了图片)。'
848
+ + '可行动项:放宽 pattern、改用 file + 行范围、调大 limit,或把图片放进 material.images。',
748
849
  { kind: 'source_empty' },
749
850
  );
750
851
  }
@@ -772,18 +873,34 @@ function applyEngine(ctx, options = {}) {
772
873
  const softBytes = softBytesFor({
773
874
  numCtx: numCtxOf(defaultModel), text: materialText, margin: ESCALATION_MARGIN,
774
875
  }) ?? LIMITS.softBodyBytes;
876
+ // 图片是共享的视觉上下文:不参与文本容量分档,也不允许升档/分批/扫描
877
+ // (它们会把同一批图片在多次请求里重复发送)。depth 只作记录。
878
+ const activePolicy = hasImages
879
+ ? Object.freeze({ ...policy, fixedDefault: true, escalate: false, batch: false, scan: false, largerStart: false, extraEscalationAttempts: 0 })
880
+ : policy;
775
881
  let actingModel;
776
882
  let pickReason;
777
- if (explicitModel !== undefined) {
883
+ if (hasImages) {
884
+ const imageModel = explicitModel ?? (cfg.modelOverride || undefined);
885
+ if (imageModel === undefined) {
886
+ throw new DecisionValidationError(
887
+ '图片材料需要视觉模型:请传 model: "clef-flash:latest"(或 clef),'
888
+ + '或在 decision 行设置 modelOverride。tev1 / nimble 不支持 vision。',
889
+ { kind: 'invalid_request' },
890
+ );
891
+ }
892
+ actingModel = imageModel;
893
+ pickReason = explicitModel === undefined ? 'row-model-override' : 'explicit';
894
+ } else if (explicitModel !== undefined) {
778
895
  actingModel = explicitModel;
779
896
  pickReason = 'explicit';
780
- } else if (policy.fixedDefault) {
897
+ } else if (activePolicy.fixedDefault) {
781
898
  actingModel = defaultModel;
782
899
  pickReason = 'depth-quick';
783
900
  } else {
784
901
  // deep:候选先剔掉「不比默认档大」的条目;剔空则回落完整阶梯(宁可从小档起步,
785
902
  // 也不要在阶梯只有一档时无档可选)。
786
- const candidates = policy.largerStart
903
+ const candidates = activePolicy.largerStart
787
904
  ? effectiveLadder.filter((entry) => Number.isFinite(entry?.numCtx) && entry.numCtx > (numCtxOf(defaultModel) ?? 0))
788
905
  : effectiveLadder;
789
906
  const picked = selectByCapacity({
@@ -798,8 +915,8 @@ function applyEngine(ctx, options = {}) {
798
915
  pickReason = picked.reason;
799
916
  }
800
917
  // R1(用户裁定):显式选择要记住(供兜底与回显),但**不**强制后续小材料也用它——
801
- // 那正是 A 方案要消灭的浪费。
802
- sticky.state = { model: actingModel, reason: pickReason };
918
+ // 那正是 A 方案要消灭的浪费。图片模型不写粘滞,避免污染后续文本调用。
919
+ if (!hasImages) sticky.state = { model: actingModel, reason: pickReason };
803
920
 
804
921
  // ---- 预算与分批(D6:批大小是实现细节,只改请求次数、不改返回结构)----
805
922
  const bodyOf = (model, slice, keyOffset, materialOverride) => toWireRequest({
@@ -812,6 +929,7 @@ function applyEngine(ctx, options = {}) {
812
929
  model,
813
930
  keepAlive,
814
931
  keyOffset,
932
+ images: hasImages ? images.map((image) => image.base64) : undefined,
815
933
  });
816
934
  const bodyBytesOf = (model, slice, keyOffset = 0) => Buffer.byteLength(JSON.stringify(bodyOf(model, slice, keyOffset)), 'utf8');
817
935
  /** 批预算:窗口 × 余量 × 密度算出的**请求体**字节上限(密度按材料 + 判据 + 本批文本估)。 */
@@ -819,13 +937,16 @@ function applyEngine(ctx, options = {}) {
819
937
  const text = `${materialText}\n${rubric ?? ''}\n${JSON.stringify(mode === 'items' ? { type: batchType, criteria: batchCriteria, items: slice } : slice)}`;
820
938
  return softBytesFor({ numCtx: numCtxOf(model), text, margin: ESCALATION_MARGIN }) ?? LIMITS.softBodyBytes;
821
939
  };
822
- /** 64 KiB 硬上限守卫(body 由本层组装,故守卫也归本层;v2 时期它曾住在校验层)。 */
940
+ /** 请求体硬上限:文本 64 KiB;带图放宽到 32 MiB。 */
823
941
  const guardBody = (body) => {
942
+ const limit = hasImages ? LIMITS.maxImageBodyBytes : LIMITS.maxBodyBytes;
824
943
  const bytes = Buffer.byteLength(JSON.stringify(body), 'utf8');
825
- if (bytes > LIMITS.maxBodyBytes) {
944
+ if (bytes > limit) {
826
945
  throw new DecisionValidationError(
827
- `请求体 ${bytes} 字节,超过服务端上限 ${LIMITS.maxBodyBytes} 字节(64 KiB);`
828
- + '可行动项:缩小 material,或减少本次项数/问题数(depth: normal 会自动分批,但单个单元不可再分)。',
946
+ `请求体 ${bytes} 字节,超过服务端上限 ${limit} 字节${hasImages ? '(带图 32 MiB)' : '(64 KiB)'};`
947
+ + (hasImages
948
+ ? '可行动项:减少图片数量/体积,或减少本次项数/问题数(带图请求不自动分批)。'
949
+ : '可行动项:缩小 material,或减少本次项数/问题数(depth: normal 会自动分批,但单个单元不可再分)。'),
829
950
  { kind: 'invalid_request' },
830
951
  );
831
952
  }
@@ -833,7 +954,8 @@ function applyEngine(ctx, options = {}) {
833
954
  };
834
955
 
835
956
  // quick:既不分批也不升档,超预算**就地报错**(绝不悄悄分批)——ADR-14。
836
- if (policy.fixedDefault && bodyBytesOf(actingModel, units) > budgetOf(actingModel, units)) {
957
+ // 图片走独立的 32 MiB body 守卫,不受文本预算约束。
958
+ if (!hasImages && activePolicy.fixedDefault && bodyBytesOf(actingModel, units) > budgetOf(actingModel, units)) {
837
959
  throw new DecisionValidationError(
838
960
  `depth: quick 既不分批也不升档,但本次请求体约 ${bodyBytesOf(actingModel, units)} B,`
839
961
  + `超过 ${actingModel} 的可用预算 ${budgetOf(actingModel, units)} B。可行动项:`
@@ -845,7 +967,7 @@ function applyEngine(ctx, options = {}) {
845
967
 
846
968
  /** 按预算把 units 切成批(贪心)。单个 unit 就超预算时仍单独成一批(unit 不可再分)。 */
847
969
  const planBatches = (slice) => {
848
- if (!policy.batch) return [{ slice, keyOffset: 0 }];
970
+ if (!activePolicy.batch) return [{ slice, keyOffset: 0 }];
849
971
  const out = [];
850
972
  let current = [];
851
973
  let offset = 0;
@@ -862,8 +984,9 @@ function applyEngine(ctx, options = {}) {
862
984
  if (current.length > 0) out.push({ slice: current, keyOffset: offset });
863
985
  return out.length === 0 ? [{ slice: [], keyOffset: 0 }] : out;
864
986
  };
865
- const batches = planBatches(units);
866
- const effectiveEscalationMaxAttempts = escalationMaxAttempts + (policy.extraEscalationAttempts ?? 0);
987
+ // 图片请求必须一次发出:分批会把同一批图片重复发送,放大成本且可能改变模型判断。
988
+ const batches = hasImages ? [{ slice: units, keyOffset: 0 }] : planBatches(units);
989
+ const effectiveEscalationMaxAttempts = escalationMaxAttempts + (activePolicy.extraEscalationAttempts ?? 0);
867
990
  const startedAt = Date.now();
868
991
  /**
869
992
  * 实际尝试过的**档**(成功项的 failureKind 为 null)——cost.attempts 与升档终态共用。
@@ -919,7 +1042,7 @@ function applyEngine(ctx, options = {}) {
919
1042
  recordAttempt(cursor, failure.kind ?? 'unknown');
920
1043
 
921
1044
  // 只有容量/溢出类失败才换档:超时、连不上、500、模型未下载都不是换档能解决的(r7 P0)。
922
- const escalateAllowed = escalationEnabled && policy.escalate && explicitModel === undefined;
1045
+ const escalateAllowed = !hasImages && escalationEnabled && activePolicy.escalate && explicitModel === undefined;
923
1046
  if (!escalateAllowed || !isCapacityError(failure)) return { ok: false, error: failure };
924
1047
 
925
1048
  for (let attemptNo = 0; attemptNo < effectiveEscalationMaxAttempts; attemptNo += 1) {
@@ -1052,12 +1175,12 @@ function applyEngine(ctx, options = {}) {
1052
1175
  }
1053
1176
  // 升档已穷尽:按 depth 决定能否转内部分块(请求语义为 inline,故非 deep 一律判 fail)。
1054
1177
  const decision = planAction({
1055
- requested: policy.scan ? 'auto' : 'inline',
1178
+ requested: activePolicy.scan ? 'auto' : 'inline',
1056
1179
  hasSource: material.source !== undefined,
1057
1180
  failure: outcome.error,
1058
1181
  escalationEnabled: false,
1059
1182
  escalation: undefined,
1060
- scanAvailable: policy.scan,
1183
+ scanAvailable: activePolicy.scan,
1061
1184
  });
1062
1185
  if (decision.action !== 'scan') throw outcome.error;
1063
1186
  await scanBatch(batch.slice, batch.keyOffset);
@@ -1084,12 +1207,16 @@ function applyEngine(ctx, options = {}) {
1084
1207
  : material.source?.truncated === true;
1085
1208
 
1086
1209
  const notes = [];
1087
- // 选档结论**必须是首行**(契约:`docs/architecture-current.md` §1.3 第 4 步 / §3.2 / §6 #8)。
1210
+ // 选档结论**必须是首行**(契约:`docs/architecture.md` §1.3 第 4 步 / §3.2 / §6 #8)。
1088
1211
  // 其余提示(体积归因 / 低置信点名 / 分批 / 分块 / 未答)一律排在它之后 —— 旧实现把它 push
1089
1212
  // 在所有提示之后,任何一条告警都会把首行挤掉(task-12 复审 P1)。
1090
1213
  notes.push(escalated
1091
1214
  ? `模型选择:从 ${actingModel} 起步,容量溢出后升档到 ${finalModel}。`
1092
1215
  : `模型选择:${finalModel}(${pickReason})。`);
1216
+ if (hasImages) {
1217
+ notes.push(`图片:${images.length} 张 / ${imageBytes} B(base64 后 ${imageBase64Bytes} B);使用视觉模型 ${finalModel};`
1218
+ + `图片请求禁用升档/分批/扫描${depth === 'quick' ? '' : `(depth: ${depth} 已忽略)`}。`);
1219
+ }
1093
1220
  // 体积归因(M2):body 大不一定是材料大。问题文本占多数时,「改材料」是误导
1094
1221
  // —— 调用方往往已经用了引用式材料,该瘦的是**重复文本**(真机样例:材料 6922B、
1095
1222
  // 请求体 26056B,其中 73% 是 35 遍同样的判据)。v3 让共享判据只写一次,这句提示仍保留。
@@ -1097,7 +1224,7 @@ function applyEngine(ctx, options = {}) {
1097
1224
  ? units.map((item, index) => ({ name: String(index + 1), type: batchType, instructions: item, criteria: batchCriteria }))
1098
1225
  : units;
1099
1226
  const bodyFacts = analyzeBody(materialText, synthesized);
1100
- if (bodyWarning !== false && bodyBytes > effectiveSoftBodyBytes) {
1227
+ if (!hasImages && bodyWarning !== false && bodyBytes > effectiveSoftBodyBytes) {
1101
1228
  notes.push(bodyFacts.questionShare > 0.5
1102
1229
  ? `请求体 ${bodyBytes} 字节,已超过建议的 ${effectiveSoftBodyBytes} 字节,但其中 `
1103
1230
  + `${Math.round(bodyFacts.questionShare * 100)}% 是问题文本(材料只有 ${bodyFacts.stateBytes}B)。`
@@ -1108,7 +1235,7 @@ function applyEngine(ctx, options = {}) {
1108
1235
  // **这条提示与体积阈值无关**(Lead 裁定 2026-10-16):它讲的是**文本形状**(重复 / 问题占多数),
1109
1236
  // 任何体积下都有用 —— 不要给它补 `bodyBytes > effectiveSoftBodyBytes` 的闸门(那会把一个常开
1110
1237
  // 的合规提示变成只在超限时才出现的提示)。守卫见 test/tool.test.mjs 的「常开」用例。
1111
- if (bodyWarning !== false && bodyFacts.hint !== null) notes.push(bodyFacts.hint);
1238
+ if (!hasImages && bodyWarning !== false && bodyFacts.hint !== null) notes.push(bodyFacts.hint);
1112
1239
  if (batches.length > 1) {
1113
1240
  notes.push(`本次按预算分了 ${batches.length} 批(实现细节):results 已按序合并,调用方看到的仍是 ${units.length} 条结果。`);
1114
1241
  }
@@ -1144,7 +1271,7 @@ function applyEngine(ctx, options = {}) {
1144
1271
  aborted: scanFacts.aborted,
1145
1272
  },
1146
1273
  material: material.source === undefined
1147
- ? { source: 'inline', bytes: Buffer.byteLength(materialText, 'utf8') }
1274
+ ? { source: 'inline', bytes: Buffer.byteLength(materialText, 'utf8'), ...(hasImages ? { images: { count: images.length, bytes: imageBytes, encodedBytes: imageBase64Bytes, formats: images.map((image) => image.mime) } } : {}) }
1148
1275
  : material.source === 'tool'
1149
1276
  // 上游委托(规格 §5):原始输出**无条件**转储,路径在这里回传 —— 「不吞掉」的落点。
1150
1277
  ? {
@@ -1156,7 +1283,7 @@ function applyEngine(ctx, options = {}) {
1156
1283
  spillPath: material.spillPath,
1157
1284
  ...(material.exitCode === undefined ? {} : { exitCode: material.exitCode }),
1158
1285
  }
1159
- : { source: material.source.kind, bytes: Buffer.byteLength(materialText, 'utf8'), descriptor: material.source },
1286
+ : { source: material.source.kind, bytes: Buffer.byteLength(materialText, 'utf8'), descriptor: material.source, ...(hasImages ? { images: { count: images.length, bytes: imageBytes, encodedBytes: imageBase64Bytes, formats: images.map((image) => image.mime) } } : {}) },
1160
1287
  cost: {
1161
1288
  model: finalModel,
1162
1289
  inputTokens,
package/lib/validate.js CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { WIRE_TYPES } from './wire.js';
13
13
  import { EVENT_TYPES } from './event-log.js';
14
+ import { isUnsafePath } from './source.js';
14
15
 
15
16
  export const LIMITS = {
16
17
  maxQuestions: 64,
@@ -43,6 +44,14 @@ export const LIMITS = {
43
44
  */
44
45
  maxScoreLevels: 10,
45
46
  maxBodyBytes: 65536,
47
+ /** 带图片的请求体上限:上游 `/v1/systemone` 对带图请求放宽到 32 MiB。 */
48
+ maxImageBodyBytes: 32 * 1024 * 1024,
49
+ /** 单次请求最多图片数(本插件策略值,不是服务端硬限)。 */
50
+ maxImages: 8,
51
+ /** 单张图片原始字节上限(base64 编码前)。 */
52
+ maxImageBytes: 16 * 1024 * 1024,
53
+ /** 单次请求图片原始字节总量上限(base64 编码前)。 */
54
+ maxTotalImageBytes: 24 * 1024 * 1024,
46
55
  /**
47
56
  * 单份引用式材料的读取上限(字节)。**这是 anti-OOM 的读盘守卫,不是模型容量**:
48
57
  * 超过它的文件不能靠 `stateSource` 一次读入(`readBytes` 抛 `FS_TOO_LARGE`,不截断),
@@ -430,41 +439,80 @@ export function resolveDepth(value) {
430
439
  * | { kind: 'glob', fields: { pattern: string, limit?: number } }}
431
440
  * @throws {DecisionValidationError} 形状不合法
432
441
  */
442
+ function normalizeImagePaths(raw) {
443
+ if (raw === undefined || raw === null) return [];
444
+ const list = typeof raw === 'string' ? [raw] : Array.isArray(raw) ? raw : null;
445
+ if (list === null) {
446
+ throw new DecisionValidationError('material.images 必须是图片路径字符串或字符串数组。', { kind: 'material_invalid' });
447
+ }
448
+ if (list.length === 0) {
449
+ throw new DecisionValidationError('material.images 不能是空数组;至少给一张图片。', { kind: 'material_invalid' });
450
+ }
451
+ if (list.length > LIMITS.maxImages) {
452
+ throw new DecisionValidationError(
453
+ `material.images 一次最多 ${LIMITS.maxImages} 张(收到 ${list.length} 张)。`,
454
+ { kind: 'material_invalid' },
455
+ );
456
+ }
457
+ return list.map((path, index) => {
458
+ if (typeof path !== 'string' || path.trim() === '') {
459
+ throw new DecisionValidationError(`material.images[${index}] 必须是非空路径字符串。`, { kind: 'material_invalid' });
460
+ }
461
+ if (isUnsafePath(path)) {
462
+ throw new DecisionValidationError(
463
+ `material.images[${index}] 必须是工作区根内的相对路径(拒绝绝对路径与 .. 段):${path}`,
464
+ { kind: 'material_invalid' },
465
+ );
466
+ }
467
+ return path;
468
+ });
469
+ }
470
+
433
471
  export function normalizeMaterial(material) {
434
472
  if (typeof material === 'string') {
435
473
  if (material.trim() === '') {
436
474
  throw new DecisionValidationError('material 不能是空字符串;它就是所有判定共享的那份材料。', { kind: 'material_invalid' });
437
475
  }
438
- return { kind: 'inline', text: material };
476
+ return { kind: 'inline', text: material, images: [] };
439
477
  }
440
478
  if (!isPlainObject(material)) {
441
479
  throw new DecisionValidationError(
442
- `material 必须是字符串(内联材料)或 {file:{...}} / {glob:{...}} 对象(收到 ${describe(material)})。`,
480
+ `material 必须是字符串(内联材料)或对象(text / file / glob / images / tool;收到 ${describe(material)})。`,
443
481
  { kind: 'material_invalid' },
444
482
  );
445
483
  }
446
484
 
447
- // 顶层只认 file / glob(fail-closed):以前未知键被**静默丢弃**(`{file:{path},extra:1}` 的 extra
448
- // 无声消失),而 file/glob **内部**的未知键是报错的 —— 同一层两种严格度,模型写错键时得不到任何反馈。
449
- // 端到端本来被宿主 schema(oneOf matched 0)兜住,这里补的是直接调用方与纵深防御(task-14 复审 P2-7)。
450
- const unknownTop = Object.keys(material).filter((key) => key !== 'file' && key !== 'glob');
485
+ // 顶层只认 text / file / glob / images(tool 在 tool.js 的委托分支单独处理):
486
+ // 未知键一律拒绝,避免模型写错键时被静默丢弃。
487
+ const unknownTop = Object.keys(material).filter((key) => !['text', 'file', 'glob', 'images'].includes(key));
451
488
  if (unknownTop.length > 0) {
452
489
  throw new DecisionValidationError(
453
- `material 对象只接受 file 或 glob(多出 ${JSON.stringify(unknownTop)});内联材料请直接传字符串。`,
490
+ `material 对象只接受 text / file / glob / images(多出 ${JSON.stringify(unknownTop)});内联材料也可直接传字符串。`,
454
491
  { kind: 'material_invalid' },
455
492
  );
456
493
  }
494
+ const hasText = material.text !== undefined && material.text !== null;
457
495
  const hasFile = material.file !== undefined && material.file !== null;
458
496
  const hasGlob = material.glob !== undefined && material.glob !== null;
497
+ const images = normalizeImagePaths(material.images);
498
+ if (hasText && (hasFile || hasGlob)) {
499
+ throw new DecisionValidationError('material 的 text 与 file / glob 只能给一个(避免「材料是哪份」有歧义)。', { kind: 'material_conflict' });
500
+ }
459
501
  if (hasFile && hasGlob) {
460
502
  throw new DecisionValidationError('material 的 file 与 glob 只能给一个(两个都给会让「材料是哪份」有歧义)。', { kind: 'material_conflict' });
461
503
  }
462
- if (!hasFile && !hasGlob) {
504
+ if (!hasText && !hasFile && !hasGlob && images.length === 0) {
463
505
  throw new DecisionValidationError(
464
- `material 对象必须含 file 或 glob 之一(收到键 ${JSON.stringify(Object.keys(material))})。`,
506
+ `material 对象必须含 text / file / glob / images 至少一个(收到键 ${JSON.stringify(Object.keys(material))})。`,
465
507
  { kind: 'material_invalid' },
466
508
  );
467
509
  }
510
+ if (hasText) {
511
+ if (typeof material.text !== 'string' || material.text.trim() === '') {
512
+ throw new DecisionValidationError('material.text 必须是非空字符串。', { kind: 'material_invalid' });
513
+ }
514
+ return { kind: 'inline', text: material.text, images };
515
+ }
468
516
  if (hasFile) {
469
517
  const rawFile = material.file;
470
518
  const file = typeof rawFile === 'string' ? { path: rawFile } : rawFile;
@@ -481,24 +529,28 @@ export function normalizeMaterial(material) {
481
529
  if (typeof file.path !== 'string' || file.path.trim() === '') {
482
530
  throw new DecisionValidationError('material.file.path 必须是非空字符串(工作区相对路径)。', { kind: 'material_invalid' });
483
531
  }
484
- return { kind: 'file', fields: { path: file.path, startLine: file.startLine, endLine: file.endLine } };
485
- }
486
- const rawGlob = material.glob;
487
- const glob = typeof rawGlob === 'string' ? { pattern: rawGlob } : rawGlob;
488
- if (!isPlainObject(glob)) {
489
- throw new DecisionValidationError(`material.glob 必须是对象或字符串模式(收到 ${describe(rawGlob)})。`, { kind: 'material_invalid' });
490
- }
491
- const unknown = Object.keys(glob).filter((key) => key !== 'pattern' && key !== 'limit');
492
- if (unknown.length > 0) {
493
- throw new DecisionValidationError(
494
- `material.glob 只接受 pattern / limit(多出 ${JSON.stringify(unknown)})。`,
495
- { kind: 'material_invalid' },
496
- );
532
+ return { kind: 'file', fields: { path: file.path, startLine: file.startLine, endLine: file.endLine }, images };
497
533
  }
498
- if (typeof glob.pattern !== 'string' || glob.pattern.trim() === '') {
499
- throw new DecisionValidationError('material.glob.pattern 必须是非空字符串(如 docs/*.md)。', { kind: 'material_invalid' });
534
+ if (hasGlob) {
535
+ const rawGlob = material.glob;
536
+ const glob = typeof rawGlob === 'string' ? { pattern: rawGlob } : rawGlob;
537
+ if (!isPlainObject(glob)) {
538
+ throw new DecisionValidationError(`material.glob 必须是对象或字符串模式(收到 ${describe(rawGlob)})。`, { kind: 'material_invalid' });
539
+ }
540
+ const unknown = Object.keys(glob).filter((key) => key !== 'pattern' && key !== 'limit');
541
+ if (unknown.length > 0) {
542
+ throw new DecisionValidationError(
543
+ `material.glob 只接受 pattern / limit(多出 ${JSON.stringify(unknown)})。`,
544
+ { kind: 'material_invalid' },
545
+ );
546
+ }
547
+ if (typeof glob.pattern !== 'string' || glob.pattern.trim() === '') {
548
+ throw new DecisionValidationError('material.glob.pattern 必须是非空字符串(如 docs/*.md)。', { kind: 'material_invalid' });
549
+ }
550
+ return { kind: 'glob', fields: { pattern: glob.pattern, limit: glob.limit }, images };
500
551
  }
501
- return { kind: 'glob', fields: { pattern: glob.pattern, limit: glob.limit } };
552
+ // images-only:state 为空串,图片由 wire 的 images 字段承载。
553
+ return { kind: 'inline', text: '', images };
502
554
  }
503
555
 
504
556
  /**
@@ -509,17 +561,17 @@ export function normalizeMaterial(material) {
509
561
  *
510
562
  * @param {unknown} material 模型给的 `material`
511
563
  * @param {{ read?: Function, signal?: AbortSignal, cwd?: string }} [io] 读取器与读取选项
512
- * @returns {Promise<{ text: string, source: object|undefined }>} 读取器给出的 `source` 原样保留
564
+ * @returns {Promise<{ text: string, source: object|undefined, images: string[] }>} 读取器给出的 `source` 原样保留;`images` 是待读的图片路径
513
565
  * @throws {DecisionValidationError} 形状不合法,或缺少读取器
514
566
  */
515
567
  export async function resolveMaterial(material, { read, signal, cwd } = {}) {
516
568
  const shape = normalizeMaterial(material);
517
- if (shape.kind === 'inline') return { text: shape.text, source: undefined };
569
+ if (shape.kind === 'inline') return { text: shape.text, source: undefined, images: shape.images ?? [] };
518
570
  if (typeof read !== 'function') {
519
571
  throw new DecisionValidationError('读取引用式 material 需要注入读取器(本模块不做 I/O)。', { kind: 'invalid_request' });
520
572
  }
521
573
  const loaded = await read({ kind: shape.kind, ...shape.fields }, { signal, cwd });
522
- return { text: loaded.state, source: loaded.source };
574
+ return { text: loaded.state, source: loaded.source, images: shape.images ?? [] };
523
575
  }
524
576
 
525
577
  /**
package/lib/wire.js CHANGED
@@ -48,10 +48,13 @@ const RUBRIC_LABEL = '判据:';
48
48
  * @returns {{ model: string, state: unknown, questions: Record<string, object>, keep_alive?: string|number }}
49
49
  */
50
50
  export function toWireRequest({
51
- material, rubric, items, type, criteria, questions, model, keepAlive, keyOffset = 0,
51
+ material, rubric, items, type, criteria, questions, model, keepAlive, keyOffset = 0, images,
52
52
  } = {}) {
53
+ // 图片-only 时 material 是空串:不要拼出以换行开头的 state。
53
54
  const state = typeof rubric === 'string' && rubric !== ''
54
- ? `${material}${String.fromCharCode(10, 10)}${RUBRIC_LABEL}${rubric}`
55
+ ? (String(material).trim() === ''
56
+ ? `${RUBRIC_LABEL}${rubric}`
57
+ : `${material}${String.fromCharCode(10, 10)}${RUBRIC_LABEL}${rubric}`)
55
58
  : material;
56
59
 
57
60
  const batch = Array.isArray(items) && items.length > 0;
@@ -70,7 +73,9 @@ export function toWireRequest({
70
73
  },
71
74
  ]));
72
75
 
73
- const body = { model, state, questions: questionsByName };
76
+ const body = { model, state };
77
+ if (Array.isArray(images) && images.length > 0) body.images = images;
78
+ body.questions = questionsByName;
74
79
  if (keepAlive !== undefined) body.keep_alive = keepAlive;
75
80
  return body;
76
81
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-decision-tools",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "DSH agent tools for local Ollama System One decision models: classify, answer yes/no, and score against a rubric in one batched request.",
5
5
  "keywords": [
6
6
  "dsh-plugin",
@@ -61,6 +61,10 @@
61
61
  "test": "node --test \"test/*.test.mjs\"",
62
62
  "link-peers": "node scripts/link-peers.mjs"
63
63
  },
64
+ "devDependencies": {
65
+ "@deepseek-ai/dsh-tools": "0.2.1-rc.2",
66
+ "@deepseek-ai/schemastery": "3.18.5"
67
+ },
64
68
  "peerDependencies": {
65
69
  "@deepseek-ai/dsh-skill": ">=0.2.0-rc.2",
66
70
  "@deepseek-ai/cordis": ">=4.0.1",