dsh-tool-vision 0.6.3 → 0.7.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/README.md CHANGED
@@ -10,12 +10,12 @@ Part of the [DeepSeek Harness Enhancement Suite](https://github.com/Scorp1o117/d
10
10
 
11
11
  External vision model for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness).
12
12
 
13
- DSH 0.1.1 adds native image input for DeepSeek's vision catalog. This plugin
14
- remains useful when you want a separate OpenAI-compatible vision endpoint,
15
- pixel-level image tools, screenshots, or a text-model bridge. The harness
16
- derives every model request strictly from the session log (`llm/stream`
17
- requests must equal the durable derivation — the agent-loop invariant), so the
18
- bridge keeps its conversion inside that durable path:
13
+ DSH 0.1.1 adds native image input for DeepSeek's vision catalog. This plugin
14
+ remains useful when you want a separate OpenAI-compatible vision endpoint,
15
+ pixel-level image tools, screenshots, or a text-model bridge. The harness
16
+ derives every model request strictly from the session log (`llm/stream`
17
+ requests must equal the durable derivation — the agent-loop invariant), so the
18
+ bridge keeps its conversion inside that durable path:
19
19
 
20
20
  1. **`inspect_image` tool** — sends an image (local file, or http(s) URL) to
21
21
  **any OpenAI-compatible** `/chat/completions` endpoint that supports
@@ -82,6 +82,9 @@ Or load it from a local path without npm:
82
82
  | `bridgePreviewScanIntervalMs` | `2000` | Fallback scan interval for the preview scanner (ms); `0` disables the fallback. |
83
83
  | `bridgePreviewHideHint` | `true` | Hide the bridged hint text once the preview image has loaded (kept on failure — safe degradation). |
84
84
  | `bridgeAutoImage` | `true` | While the bridge is on, report image input capability for **every** model to the host admission gate, so pasted images are accepted on text-only models without hand-editing provider configs. |
85
+ | `sendSessionHeader` | `true` | Send a stable session-id header on vision requests. OpenCode Go and similar gateways require `x-opencode-session` (one stable id per conversation); requests without it may error from 2026-09-06. |
86
+ | `sessionHeaderName` | `x-opencode-session` | Header name carrying the session id. |
87
+ | `sessionId` | `''` | Fixed session id for calls without a dsh session context; empty = auto (current dsh session id, else a stable per-process random id). |
85
88
 
86
89
  ## Image bridge setup
87
90
 
@@ -166,11 +169,11 @@ Example endpoints (`baseURL`):
166
169
  > - This plugin is a standard **profile bundle** (`dsh.bundle.patch`):
167
170
  > `dsh plugin --profile web add dsh-tool-vision` installs and mounts it in
168
171
  > one step — no manual `cordis.patch.yml` edits needed.
169
- > - Settings changes hot-apply (no restart needed).
170
- > - Version 0.6.3 and newer require DSH `0.1.0-rc.7` or newer and are tested
171
- > against `0.1.0-rc.7`, `0.1.0-rc.8`, and `0.1.1-rc.1`.
172
- > - DSH `0.1.0-rc.6` users must pin `dsh-tool-vision@0.6.1`, the last release
173
- > carrying the legacy settings-allowlist compatibility patch.
172
+ > - Settings changes hot-apply (no restart needed).
173
+ > - Version 0.6.3 and newer require DSH `0.1.0-rc.7` or newer and are tested
174
+ > against `0.1.0-rc.7`, `0.1.0-rc.8`, and `0.1.1-rc.1`.
175
+ > - DSH `0.1.0-rc.6` users must pin `dsh-tool-vision@0.6.1`, the last release
176
+ > carrying the legacy settings-allowlist compatibility patch.
174
177
 
175
178
  ## Pixel-level vision tools (v0.6.0, ported from dsh-vision-router)
176
179
 
package/README.zh.md CHANGED
@@ -57,6 +57,9 @@ DSH 0.1.1 已为 DeepSeek 视觉模型目录加入原生图片输入。本插件
57
57
  | `bridgePreviewScanIntervalMs` | `2000` | 预览兜底扫描间隔(毫秒);`0` 关闭兜底 |
58
58
  | `bridgePreviewHideHint` | `true` | 图片加载成功后隐藏桥接提示文本(失败时保留,安全降级) |
59
59
  | `bridgeAutoImage` | `true` | 桥接开启时向宿主准入检查报告**所有**模型都支持图片输入,纯文本模型也能直接粘贴图片,无需手动改 provider 配置 |
60
+ | `sendSessionHeader` | `true` | 给视觉请求发送稳定的会话标识头。OpenCode Go 等网关要求每请求带 `x-opencode-session`(一个对话一个稳定 ID),缺失的请求在 2026-09-06 起可能报错 |
61
+ | `sessionHeaderName` | `x-opencode-session` | 承载会话 ID 的头名 |
62
+ | `sessionId` | `''` | 固定会话 ID(后台/无 dsh 会话调用时用);留空则自动:优先取当前 dsh 会话 ID,否则用进程级稳定随机 ID |
60
63
 
61
64
  ## 图片桥配置
62
65
 
package/client.js CHANGED
@@ -80,6 +80,11 @@ window.__ModuleLoader__.load({
80
80
  fieldBridgePreviewHideHint: "图片加载成功后隐藏桥接提示文本",
81
81
  fieldBridgeAutoImage: "自动声明图片能力(纯文本模型也能粘贴图片)",
82
82
  fieldDesktopScreenshot: "桌面截屏工具(vision_screenshot,默认关闭)",
83
+ fieldSendSessionHeader: "发送会话标识头(x-opencode-session)",
84
+ fieldSessionHeaderName: "会话标识头名称",
85
+ fieldSessionId: "固定会话 ID(留空 = 自动)",
86
+ hintSendSessionHeader: "OpenCode Go 等网关要求每个请求带稳定的会话标识(一个对话一个 ID);请求不带可能报错。默认发送:优先用当前 dsh 会话 ID,无会话上下文时用下方固定 ID,再否则用进程级随机 ID。",
87
+ hintSessionId: "后台/无会话调用时使用的固定会话标识(如 dsh-vision-<hostname>);留空则每进程自动生成一个稳定 ID。",
83
88
  hintBridgePreview: "纯展示层:不影响模型侧文本与 inspect_image 调用。",
84
89
  hintBridgePreviewScanIntervalMs: "默认 2000ms;越小响应越快,越大越省资源。",
85
90
  hintBridgePreviewHideHint: "加载失败时保留文本(安全降级,绝不出现既无图又无字)。",
@@ -119,6 +124,11 @@ window.__ModuleLoader__.load({
119
124
  fieldBridgePreviewHideHint: "Hide the bridged hint text once the image has loaded",
120
125
  fieldBridgeAutoImage: "Auto-declare image capability (paste images on text-only models)",
121
126
  fieldDesktopScreenshot: "Desktop screenshot tool (vision_screenshot, off by default)",
127
+ fieldSendSessionHeader: "Send session-id header (x-opencode-session)",
128
+ fieldSessionHeaderName: "Session-id header name",
129
+ fieldSessionId: "Fixed session id (empty = auto)",
130
+ hintSendSessionHeader: "OpenCode Go and similar gateways require a stable per-conversation id on every request; requests without it may error. When on: prefer the current dsh session id, else the fixed id below, else a per-process random id.",
131
+ hintSessionId: "Fixed session id used for background calls without a dsh session; leave empty for a per-process stable random id.",
122
132
  hintBridgePreview: "Pure display layer: the model-facing text and the inspect_image chain are untouched.",
123
133
  hintBridgePreviewScanIntervalMs: "Default 2000ms; lower is snappier, higher is cheaper.",
124
134
  hintBridgePreviewHideHint: "Text is kept on load failure (safe degradation, never no image AND no text).",
@@ -150,7 +160,10 @@ window.__ModuleLoader__.load({
150
160
  { key: "bridgePreviewScanIntervalMs", label: "fieldBridgePreviewScanIntervalMs", type: "number" },
151
161
  { key: "bridgePreviewHideHint", label: "fieldBridgePreviewHideHint", type: "checkbox" },
152
162
  { key: "bridgeAutoImage", label: "fieldBridgeAutoImage", type: "checkbox" },
153
- { key: "desktopScreenshot", label: "fieldDesktopScreenshot", type: "checkbox" }
163
+ { key: "desktopScreenshot", label: "fieldDesktopScreenshot", type: "checkbox" },
164
+ { key: "sendSessionHeader", label: "fieldSendSessionHeader", type: "checkbox" },
165
+ { key: "sessionHeaderName", label: "fieldSessionHeaderName", type: "text" },
166
+ { key: "sessionId", label: "fieldSessionId", type: "text" }
154
167
  ];
155
168
  var ZH_HINTS = {
156
169
  apiKey: "apiKeyHint",
@@ -183,11 +196,16 @@ window.__ModuleLoader__.load({
183
196
  var [error, setError] = react.useState(null);
184
197
 
185
198
  react.useEffect(function () {
186
- if (typeof scope.load === "function") scope.load();
199
+ if (typeof scope.load === "function") scope.load();
187
200
  var alive = true;
188
201
  var sync = function () { if (alive) setSnapshot(scope.getSnapshot()); };
189
202
  var un = typeof scope.subscribe === "function" ? scope.subscribe(sync) : null;
190
- return function () { alive = false; if (un) un(); if (scope.dispose) scope.dispose(); };
203
+ // The settings scope is bound once for this plugin (apply) and shared
204
+ // across every mount of this section. Unmounting the section must only
205
+ // unsubscribe — never dispose the scope, or a later remount would find
206
+ // a `disposed` scope whose write queue no-ops, silently dropping saves
207
+ // (the value then reverts on the next describe).
208
+ return function () { alive = false; if (un) un(); };
191
209
  }, [scope]);
192
210
  // Seed the draft ONLY when the snapshot becomes ready — never on value
193
211
  // churn. settingsScope.getSnapshot() returns a fresh object per call,
@@ -246,7 +264,7 @@ window.__ModuleLoader__.load({
246
264
  });
247
265
  Promise.all(writes).then(function () {
248
266
  setBusy(false); setNotice(t("saved"));
249
- if (typeof scope.load === "function") scope.load();
267
+ if (typeof scope.load === "function") scope.load();
250
268
  }).catch(function (e) {
251
269
  setBusy(false); setError(t("error") + ": " + String(e && e.message || e));
252
270
  });
@@ -482,7 +500,7 @@ window.__ModuleLoader__.load({
482
500
  var t = ctx.locale.bind(NS);
483
501
  ctx.effect(function () { return ctx.locale.register(NS, { zh: zh, en: en }); }, "dsh-tool-vision: dictionaries");
484
502
  var scope = ctx.settingsScope.bind({ namespace: "tool-vision" });
485
- if (typeof scope.load === "function") scope.load();
503
+ if (typeof scope.load === "function") scope.load();
486
504
  attachBridgePreview(ctx, scope);
487
505
  ctx.slots.inject("settings.section", function () {
488
506
  return ctx.slots.register({
package/cordis.patch.yml CHANGED
@@ -1,8 +1,8 @@
1
- # dsh-tool-vision — bundle patch layer
2
- #
3
- # Declares the plugin as a Cordis loader entry, making the package a standard
4
- # profile bundle: `dsh plugin --profile <p> add dsh-tool-vision` installs and
5
- # mounts it without manual cordis.patch.yml edits.
6
- - insert:
7
- - id: tool-vision
8
- name: 'dsh-tool-vision'
1
+ # dsh-tool-vision — bundle patch layer
2
+ #
3
+ # Declares the plugin as a Cordis loader entry, making the package a standard
4
+ # profile bundle: `dsh plugin --profile <p> add dsh-tool-vision` installs and
5
+ # mounts it without manual cordis.patch.yml edits.
6
+ - insert:
7
+ - id: tool-vision
8
+ name: 'dsh-tool-vision'
package/index.js CHANGED
@@ -41,15 +41,15 @@ import { extname, isAbsolute, join, resolve as resolvePath, sep } from "node:pat
41
41
  import os from "node:os";
42
42
  import z from "@deepseek-ai/schemastery";
43
43
  import { defineTool } from "@deepseek-ai/dsh-tools";
44
- import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
45
44
  import { registerVisionTools, CONTENT_FILTER_RE } from "./lib/vision-tools.js";
45
+ import { sessionHeaders } from "./lib/session-header.js";
46
46
 
47
47
  /** Cordis plugin name. */
48
48
  const name = "tool-vision";
49
49
  /** The tool registry, the llm seam (model capability lookup), the attachment store, and the host web server. */
50
50
  const inject = ["tools", "llm", "attachments", "webServer"];
51
51
  /** Settings namespace owned by this plugin (Web UI settings section). */
52
- const NS = settingsNamespace("tool-vision");
52
+ const NS = "tool-vision";
53
53
 
54
54
  /**
55
55
  * Invisible prefix stamped onto every bridged hint text block. The browser
@@ -123,6 +123,17 @@ const Config = z.object({
123
123
  * provider model configs.
124
124
  */
125
125
  bridgeAutoImage: z.boolean().default(true),
126
+ /** Send a stable per-conversation id header (`x-opencode-session`) on vision
127
+ * requests. OpenCode Go (and similar OpenAI-compatible gateways) require it
128
+ * on every request; requests without the header may error. The value is the
129
+ * current dsh session id when the call runs inside one, else `sessionId`
130
+ * below, else a stable per-process random id. Set false to disable. */
131
+ sendSessionHeader: z.boolean().default(true),
132
+ /** Header name carrying the session id. */
133
+ sessionHeaderName: z.string().default("x-opencode-session"),
134
+ /** Fixed session id override for callers without a dsh session context.
135
+ * Empty = auto (per-process stable id). */
136
+ sessionId: z.string().default(""),
126
137
  });
127
138
 
128
139
  const MIME_BY_EXT = {
@@ -476,7 +487,7 @@ async function toImageUrl(target, cwd, config) {
476
487
  }
477
488
 
478
489
  /** One OpenAI-compatible chat/completions call with an image_url content part. */
479
- async function callVision(config, imageUrl, question, detail, signal) {
490
+ async function callVision(config, imageUrl, question, detail, signal, exec) {
480
491
  const key = resolveApiKey(config);
481
492
  if (!key) {
482
493
  throw new Error(
@@ -485,6 +496,7 @@ async function callVision(config, imageUrl, question, detail, signal) {
485
496
  }
486
497
  const base = config.baseURL.endsWith("/") ? config.baseURL : `${config.baseURL}/`;
487
498
  const endpoint = new URL("chat/completions", base);
499
+ const sessionHeader = sessionHeaders(config, exec);
488
500
  const controller = new AbortController();
489
501
  const timer = setTimeout(
490
502
  () => controller.abort(new Error(`vision request timed out after ${config.timeoutMs}ms`)),
@@ -505,6 +517,7 @@ async function callVision(config, imageUrl, question, detail, signal) {
505
517
  headers: {
506
518
  "Content-Type": "application/json",
507
519
  Authorization: `Bearer ${key}`,
520
+ ...sessionHeader,
508
521
  },
509
522
  body: JSON.stringify({
510
523
  model: config.model,
@@ -541,18 +554,24 @@ function apply(ctx, config) {
541
554
  // ── settings-backed configuration ─────────────────────────────────────────
542
555
  // The composition entry stays the `base` layer; a registered `tool-vision`
543
556
  // settings section (Web UI section, settings.yaml) overlays it live, so
544
- // edits hot-apply without a restart. `installSettingsSection` hands
545
- // `setSource` a GETTER (`() => scope.get()`), not the config object — keep
546
- // it and call it at use time, or `getConfig()` would return a function and
547
- // every `cfg.*` read would be undefined (apiKey included).
557
+ // edits hot-apply without a restart. `sourceGetter` is a GETTER
558
+ // (`() => scope.get()`), not the config object — keep it and call it at use
559
+ // time, or `getConfig()` would return a function and every `cfg.*` read
560
+ // would be undefined (apiKey included).
548
561
  let current = config;
549
562
  let sourceGetter = null;
550
563
  const getConfig = () => (sourceGetter ? sourceGetter() : current);
551
- installSettingsSection(ctx, NS, Config, config, {
552
- setSource: (getter) => {
553
- sourceGetter = getter;
554
- },
555
- onChange: () => {},
564
+ // Compat shim: dsh-settings 0.1.2-rc.1 removed the module-level
565
+ // `installSettingsSection` export (the provider now lives at ctx.settings).
566
+ // Inline the same logic via ctx.inject(["settings"]) — works on both
567
+ // 0.1.1 (module export wrapper) and 0.1.2 (ctx.settings) hosts.
568
+ ctx.inject(["settings"], (sctx) => {
569
+ const scope = sctx.settings.register(NS, Config, { base: config });
570
+ sourceGetter = () => scope.get();
571
+ sctx.effect(() => () => {
572
+ sourceGetter = null;
573
+ });
574
+ scope.watch(() => {});
556
575
  });
557
576
 
558
577
  // ── image bridge: pasted images become inspect_image hints on text-only models ──
@@ -608,7 +627,7 @@ function apply(ctx, config) {
608
627
  const cwd = exec.agent?.session?.header?.cwd ?? process.cwd();
609
628
  const { url, note } = await toImageUrl(args.path, cwd, cfg);
610
629
  try {
611
- const answer = await callVision(cfg, url, args.question, args.detail, exec.signal);
630
+ const answer = await callVision(cfg, url, args.question, args.detail, exec.signal, exec);
612
631
  return note === url ? answer : `${answer}\n\n(image: ${note})`;
613
632
  } catch (error) {
614
633
  const raw = error && error.message ? String(error.message) : String(error);
@@ -0,0 +1,57 @@
1
+ // dsh-tool-vision — x-opencode-session request header helper.
2
+ //
3
+ // OpenCode Go (and other OpenAI-compatible gateways) ask clients to tag every
4
+ // request with a stable per-conversation identifier so they can attribute and
5
+ // optimize the service. Requests without the header started erroring after
6
+ // 2026-09-06.
7
+ //
8
+ // This module centralizes how the plugin picks that identifier:
9
+ //
10
+ // 1. A real dsh session id, when the tool call runs inside one
11
+ // (`exec.agent?.session?.id`, or the same id on the exec context).
12
+ // This is the ideal "one stable ID per conversation".
13
+ // 2. Otherwise the configured `sessionId` (a fixed override the user can
14
+ // set in the settings panel, useful for background/standalone callers).
15
+ // 3. Otherwise a per-process random id, generated once. Stable for the
16
+ // lifetime of the plugin process, so every untagged background call
17
+ // from this instance still shares one stable id.
18
+
19
+ import { randomUUID } from 'node:crypto'
20
+
21
+ export const DEFAULT_SESSION_HEADER = 'x-opencode-session'
22
+
23
+ let fallbackId = ''
24
+ function getFallbackId() {
25
+ if (!fallbackId) fallbackId = `dsh-vision-${randomUUID()}`
26
+ return fallbackId
27
+ }
28
+
29
+ /**
30
+ * Pick a stable per-conversation id for the outgoing vision request.
31
+ *
32
+ * @param {object} cfg live tool-vision config (may carry `sessionId`).
33
+ * @param {object|undefined} exec tool execution context, when available.
34
+ * @returns {string|undefined} the id to send, or undefined if header disabled.
35
+ */
36
+ export function resolveSessionId(cfg, exec) {
37
+ if (cfg?.sendSessionHeader === false) return undefined
38
+ // Prefer an explicit, stable configured id when the user set one.
39
+ if (cfg?.sessionId) return String(cfg.sessionId)
40
+ // Real conversation id from the tool execution context, when present.
41
+ const agentSessionId =
42
+ exec?.agent?.session?.id ??
43
+ exec?.session?.id ??
44
+ exec?.sessionId ??
45
+ exec?.agent?.session?.header?.sessionId
46
+ if (agentSessionId) return String(agentSessionId)
47
+ // Background / untagged call: one stable id per plugin process.
48
+ return getFallbackId()
49
+ }
50
+
51
+ /** Build the header object to merge into a vision request's headers. */
52
+ export function sessionHeaders(cfg, exec) {
53
+ const id = resolveSessionId(cfg, exec)
54
+ if (!id) return {}
55
+ const headerName = cfg?.sessionHeaderName || DEFAULT_SESSION_HEADER
56
+ return { [headerName]: id }
57
+ }
@@ -19,6 +19,7 @@ import { execFile } from 'node:child_process'
19
19
  import { promisify } from 'node:util'
20
20
  import { pathToFileURL } from 'node:url'
21
21
  import { createRequire } from 'node:module'
22
+ import { sessionHeaders } from './session-header.js'
22
23
 
23
24
  // ── image formats ──────────────────────────────────────────────────────────
24
25
 
@@ -1144,6 +1145,7 @@ export async function callVisionModel(getConfig, prompt, imageUrls, options = {}
1144
1145
  'tool-vision: vision API key missing: set the plugin config (apiKey / apiKeyEnv) or the OPENAI_API_KEY environment variable',
1145
1146
  )
1146
1147
  }
1148
+ const sessionHeader = sessionHeaders(cfg, options.exec)
1147
1149
  const base = cfg.baseURL.endsWith('/') ? cfg.baseURL : `${cfg.baseURL}/`
1148
1150
  const endpoint = new URL('chat/completions', base)
1149
1151
  const controller = new AbortController()
@@ -1186,6 +1188,7 @@ export async function callVisionModel(getConfig, prompt, imageUrls, options = {}
1186
1188
  headers: {
1187
1189
  'Content-Type': 'application/json',
1188
1190
  Authorization: `Bearer ${key}`,
1191
+ ...sessionHeader,
1189
1192
  },
1190
1193
  body: bodyOf(),
1191
1194
  signal: controller.signal,
@@ -1263,6 +1266,7 @@ export function registerVisionTools(ctx, getConfig) {
1263
1266
  : [bytesToDataUrl(await downscaleImage(images, 4_000_000), mediaType)]
1264
1267
  const text = await callVisionModel(getConfig, instruction, bounded, {
1265
1268
  signal: options.signal ?? (options.exec && options.exec.signal),
1269
+ exec: options.exec,
1266
1270
  })
1267
1271
  return { ok: true, text }
1268
1272
  } catch (error) {
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "dsh-tool-vision",
3
- "version": "0.6.3",
3
+ "version": "0.7.0",
4
4
  "description": "DeepSeek Harness 外置视觉模型插件:inspect_image 把本地图片或 http(s) 图片 URL 发给任意 OpenAI 兼容端点,视觉模型看图的文字回答直接带回对话;附 Web UI 设置栏。",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "scripts": {
8
- "test": "node tests/preview-regex.test.mjs && node tests/preview-route.test.mjs && node tests/auto-image.test.mjs && node --test tests/vision-tools.test.js tests/vision-tools-tools.test.js && node tests/settings-scope-compat.test.mjs"
8
+ "test": "node tests/preview-regex.test.mjs && node tests/preview-route.test.mjs && node tests/auto-image.test.mjs && node --test tests/vision-tools.test.js tests/vision-tools-tools.test.js && node tests/settings-scope-compat.test.mjs"
9
9
  },
10
10
  "exports": {
11
11
  ".": "./index.js",
@@ -59,8 +59,8 @@
59
59
  },
60
60
  "peerDependencies": {
61
61
  "react": "^18.2.0",
62
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.7 || ^0.1.1-rc.1",
63
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.7 || ^0.1.1-rc.1"
62
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.7 || ^0.1.1-rc.1 || ^0.1.2-rc.1",
63
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.7 || ^0.1.1-rc.1 || ^0.1.2-rc.1"
64
64
  },
65
65
  "optionalDependencies": {
66
66
  "potrace": "^2.1.8",
@@ -68,4 +68,3 @@
68
68
  "sharp": ">=0.35.3 <1"
69
69
  }
70
70
  }
71
-