@twinsearth/w2m-dsh-plugin 0.56.0 → 0.57.1

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
@@ -4,6 +4,77 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.57.1] — 2026-10-15
8
+
9
+ **The vision status line now appears only on a bridge that actually starts — which it already did, for a reason
10
+ worth writing down.**
11
+
12
+ `visionStatus()` sits at the top of `main()`, and the configuration refusal runs at **module scope, before `main()`
13
+ is ever called**. So a bridge that refuses to start prints nothing about vision.
14
+
15
+ **I first described that as a bug and set out to move the check "after the refusal block".** Reading the file
16
+ showed the ordering was already right: **the refusal already names what is wrong, and a second complaint about
17
+ vision underneath it would bury the first.** The comment was corrected to say what the code does rather than to
18
+ describe a fix that was never needed.
19
+
20
+ **A no-behaviour-change release**, and the only reason it exists is that the comment previously implied a
21
+ sequence the file does not have.
22
+
23
+ ## [0.57.0] — 2026-10-15
24
+
25
+ **An image path becomes text evidence: the vision subprocess, and the credential path that does not touch argv.**
26
+
27
+ ### What this is, and what it deliberately is not
28
+
29
+ `src/chatops/vision.mjs` runs the vendored `vision.py` as a subprocess. **It is not the WeChat integration** —
30
+ the bridge still answers an image with a help message. The two halves are kept apart because each has a different
31
+ failure mode and can be verified alone, **and this half is verifiable today**: give it a real image and it either
32
+ returns text or a named reason.
33
+
34
+ **Verified against the real endpoint, on this machine:** a generated 32×32 red PNG, `glm-4v-plus`, **1.77 s**,
35
+ reply `"红色。"` — and `prompt_tokens` 64 → 2374, so the image genuinely reached the model rather than being
36
+ ignored.
37
+
38
+ ### The credential never enters `argv`
39
+
40
+ `vision.py` resolves its key as `--api-key` > `VISION_API_KEY` > the provider's own variable — read out of its
41
+ own source, not assumed. **Passing it on the command line would put a live key into `ps` output and into any
42
+ argument logging**, so this module only ever sets it **in the child's environment**, and the caller supplies it
43
+ from a file.
44
+
45
+ **The settings file is a parser, not a shell.** No expansion, no quoting rules, no `export` prefix: a value of
46
+ `$HOME` stays the four characters `$HOME`. **A settings file that is half a shell is a shell**, and the point of
47
+ this one is that it is read rather than executed.
48
+
49
+ ### An error message I wrote that collapsed three causes into one
50
+
51
+ My first probe of the credential printed
52
+ `==> refused; the key or the model name is not accepted` for **any** non-2xx response. The actual response was:
53
+
54
+ ```
55
+ HTTP 400 code 1210 "图片输入格式/解析错误"
56
+ ```
57
+
58
+ **That is an image-decoding failure — which is proof the credential was accepted**, because bigmodel answers
59
+ **401** for a bad key. **My hand-built base64 PNG was malformed, and I reported it as a credential problem.** One
60
+ conclusion for three different causes is the failure this repository keeps naming, and I committed it while
61
+ writing a file whose own header complains about that pattern.
62
+
63
+ The re-probe generates the PNG from `zlib.deflateSync` plus a hand-written CRC — **correct by construction** —
64
+ and splits the two questions: a text-only request isolates the key, and the image isolates the vision path. Both
65
+ returned 200.
66
+
67
+ ### One ordering the code decided and the test got wrong
68
+
69
+ The module checks the image argument, then the script, then whether the image exists. My new assertion claimed a
70
+ non-existent image outranks a missing script — **it does not, and the code was right.** The assertion was
71
+ narrowed to the ordering that is actually guaranteed: **an empty argument is rejected before the installation is
72
+ consulted.**
73
+
74
+ **17 new tests, 0 failures**, and none of them needs a network or a key — every refusal path is asserted, and the
75
+ one thing not asserted is a real model answer, because a suite that needs a credential is a suite that skips
76
+ wherever the credential is absent.
77
+
7
78
  ## [0.56.0] — 2026-10-15
8
79
 
9
80
  **The declared endpoint is now tested against the real binary, so a drifted port turns the pre-flight red.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "0.56.0",
3
+ "version": "0.57.1",
4
4
  "description": "DSH: Windows2MacOS — drive the same project on online Windows and macOS machines from one DeepSeek account, across networks and regions. P2P hole punching is the default path (relay + shared server as rendezvous, ledger and fallback), with a bundled STUN responder, Tailscale/public-VPS/tunnel shapes, Rabbit relay + per-machine Localside + eight DSH tools (replicate / split), nine aggregate verdicts and signed requests.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -116,13 +116,13 @@
116
116
  }
117
117
  },
118
118
  "scripts": {
119
- "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/chatops-fleet-join.test.mjs",
119
+ "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/chatops-fleet-join.test.mjs",
120
120
  "test:e2e": "node --test --test-force-exit test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/p2p-agent.test.mjs",
121
121
  "test:relay": "node --test --test-force-exit test/relay.test.mjs",
122
122
  "test:agent": "node --test --test-force-exit test/agent.test.mjs test/memory.test.mjs",
123
123
  "test:tools": "node --test --test-force-exit test/tools.test.mjs",
124
124
  "test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
125
- "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/chatops-fleet-join.test.mjs",
125
+ "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/mcp-http.test.mjs test/mcp-http-live.test.mjs test/vision.test.mjs test/chatops-fleet-join.test.mjs",
126
126
  "pack": "node scripts/pack.mjs",
127
127
  "verify:join": "node scripts/verify-join.mjs",
128
128
  "lint": "eslint .",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "twinsearth-w2m-dsh",
4
- "version": "0.56.0",
4
+ "version": "0.57.1",
5
5
  "description": "Cross-platform fleet execution between Windows and macOS: one DeepSeek Harness account, a Rabbit relay, and signed device identity. Ships four Agent Skills describing fleet dispatch, field-level memory acceptance, session references, and resource scheduling.",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Invoke the vendored `vision.py` as a subprocess (v0.57.0).
3
+ *
4
+ * ## What this is, and what it deliberately is not
5
+ *
6
+ * It turns an image path into text evidence by running a Python script. **It is not the WeChat integration.** The
7
+ * bridge still answers an image with a help message, and wiring this into it is a separate change -- kept apart
8
+ * because each half has a different failure mode and can be verified alone. **This half is verifiable today:**
9
+ * give it a real image and it either returns text or a named reason.
10
+ *
11
+ * ## The credential never enters `argv`
12
+ *
13
+ * `vision.py` resolves its key as `--api-key` > `VISION_API_KEY` > the provider's own variable (`ZHIPU_API_KEY`
14
+ * for `glm`). **Passing it on the command line would put a live key into `ps` output and into any argv logging**,
15
+ * so this module only ever sets it **in the child's environment**, and the caller supplies it from a file.
16
+ *
17
+ * ## Why the settings file is read here rather than exported by a shell
18
+ *
19
+ * The bridge is started by hand sometimes and by a launcher other times, **so an `export` in a shell rc is only
20
+ * sometimes in effect** -- and a credential that works until the start method changes is worse than one that
21
+ * never worked, because the failure looks like a broken API. A file with mode 0600 is read the same way
22
+ * whichever process is asking.
23
+ *
24
+ * ## Why the exit code and the stdout are both inspected
25
+ *
26
+ * `vision.py` writes its diagnosis to **stderr** and exits non-zero, and a caller that checks only stdout sees
27
+ * an empty string. It also prints the model's reply to **stdout**, so a caller that checks only the exit code
28
+ * discards the answer. Both are read, and the failure message is the script's own words rather than a
29
+ * paraphrase.
30
+ */
31
+ import { spawn } from 'node:child_process';
32
+ import { readFileSync, existsSync, statSync } from 'node:fs';
33
+ import { join } from 'node:path';
34
+ import { homedir } from 'node:os';
35
+
36
+ /** Where the vendored script lives, matching the layout the skill repository uses. */
37
+ export const DEFAULT_SKILL_DIR = join(homedir(), '.agents', 'skills', 'vision-multimodal');
38
+ /** The settings file the bridge reads. Mode 0600, one `KEY=value` per line, `provider` accepted as a key. */
39
+ export const DEFAULT_ENV_FILE = join(homedir(), '.config', 'w2m', 'vision.env');
40
+
41
+ /** Longest image this will hand to the script, in bytes. */
42
+ export const MAX_IMAGE_BYTES = 12 * 1024 * 1024;
43
+
44
+ /**
45
+ * Read a `KEY=value` settings file.
46
+ *
47
+ * Deliberately not a dotenv implementation: no expansion, no quoting rules, no `export` prefix. **A settings
48
+ * file that is half a shell is a shell**, and the whole point of this one is that it is read by a parser rather
49
+ * than executed.
50
+ *
51
+ * @param {string} path
52
+ * @returns {{values: Record<string,string>, problem: string|null}}
53
+ */
54
+ export function readEnvFile(path) {
55
+ if (!existsSync(path)) return { values: {}, problem: `no settings file at ${path}` };
56
+ let text;
57
+ try {
58
+ text = readFileSync(path, 'utf8');
59
+ } catch (error) {
60
+ return { values: {}, problem: `could not read ${path}: ${error.message}` };
61
+ }
62
+ const values = {};
63
+ for (const raw of text.split(/\r?\n/)) {
64
+ const line = raw.trim();
65
+ if (line === '' || line.startsWith('#')) continue;
66
+ const at = line.indexOf('=');
67
+ if (at < 1) return { values: {}, problem: `${path}: line ${JSON.stringify(raw.slice(0, 40))} is not KEY=value` };
68
+ const key = line.slice(0, at).trim();
69
+ let value = line.slice(at + 1).trim();
70
+ // Strip one layer of matching quotes, because a key pasted from a web page often carries them.
71
+ if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
72
+ value = value.slice(1, -1);
73
+ }
74
+ values[key] = value;
75
+ }
76
+ return { values, problem: null };
77
+ }
78
+
79
+ /** Mode on POSIX, or null where the platform has no such bits (Windows reports 0o666 for everything). */
80
+ const modeOf = (path) => {
81
+ try {
82
+ const mode = statSync(path).mode & 0o777;
83
+ return process.platform === 'win32' ? null : mode;
84
+ } catch {
85
+ return null;
86
+ }
87
+ };
88
+
89
+ /**
90
+ * Turn the settings values into the environment `vision.py` expects.
91
+ *
92
+ * `provider` is translated to `VISION_PROVIDER` because the script reads that name; everything else is passed
93
+ * through, so the file can carry `ZHIPU_API_KEY`, `VISION_API_KEY`, `VISION_BASE_URL` and `VISION_MODEL`
94
+ * without this module having to know which provider is in use.
95
+ *
96
+ * @param {Record<string,string>} values
97
+ * @param {Record<string,string>} [base] the parent environment
98
+ */
99
+ export function visionEnv(values, base = process.env) {
100
+ const env = { ...base };
101
+ for (const [key, value] of Object.entries(values)) {
102
+ env[key === 'provider' ? 'VISION_PROVIDER' : key] = value;
103
+ }
104
+ return env;
105
+ }
106
+
107
+ /** Does the resolved environment actually carry a key for the selected provider? */
108
+ const KEY_FOR = { glm: 'ZHIPU_API_KEY', qwen: 'DASHSCOPE_API_KEY', kimi: 'MOONSHOT_API_KEY', openai: 'OPENAI_API_KEY', siliconflow: 'SILICONFLOW_API_KEY' };
109
+
110
+ export function keyPresent(env) {
111
+ if (typeof env.VISION_API_KEY === 'string' && env.VISION_API_KEY !== '') return true;
112
+ const provider = env.VISION_PROVIDER ?? 'qwen';
113
+ if (provider === 'ollama') return true; // needs no key by design
114
+ const name = KEY_FOR[provider] ?? 'VISION_API_KEY';
115
+ return typeof env[name] === 'string' && env[name] !== '';
116
+ }
117
+
118
+ /**
119
+ * Run one vision command.
120
+ *
121
+ * @param {{command?: string, image: string, prompt?: string, scriptDir?: string, env: object, python?: string, timeoutMs?: number}} options
122
+ * @returns {Promise<{ok: boolean, text: string, reason: string|null, code: number|null, ms: number}>}
123
+ */
124
+ export function analyze({ command = 'chat', image, prompt, scriptDir = DEFAULT_SKILL_DIR, env, python = 'python3', timeoutMs = 120_000 }) {
125
+ const script = join(scriptDir, 'vision.py');
126
+ /**
127
+ * Caller mistakes are reported before configuration mistakes.
128
+ *
129
+ * A wrong image path and a missing script are both refusals, and the order decides which one a caller sees.
130
+ * **The image is the argument; the script is the installation.** Checking the installation first meant every
131
+ * malformed call on a machine without the skill answered "no vision.py at …" -- true, and useless, because
132
+ * the caller's own mistake was never mentioned.
133
+ */
134
+ if (typeof image !== 'string' || image === '') return Promise.resolve({ ok: false, text: '', reason: 'no image path given', code: null, ms: 0 });
135
+ if (!existsSync(script)) return Promise.resolve({ ok: false, text: '', reason: `no vision.py at ${script}`, code: null, ms: 0 });
136
+ if (!existsSync(image)) return Promise.resolve({ ok: false, text: '', reason: `image does not exist: ${image}`, code: null, ms: 0 });
137
+ try {
138
+ const size = statSync(image).size;
139
+ if (size > MAX_IMAGE_BYTES) return Promise.resolve({ ok: false, text: '', reason: `image is ${size} bytes, over the ${MAX_IMAGE_BYTES}-byte limit`, code: null, ms: 0 });
140
+ } catch (error) {
141
+ return Promise.resolve({ ok: false, text: '', reason: `could not stat the image: ${error.message}`, code: null, ms: 0 });
142
+ }
143
+
144
+ // `shell: false` and an argv array: the image path is data, never a command fragment.
145
+ const argv = [script, command, '--image', image];
146
+ if (typeof prompt === 'string' && prompt !== '') argv.push('--prompt', prompt);
147
+ const started = Date.now();
148
+
149
+ return new Promise((resolve) => {
150
+ const child = spawn(python, argv, { env, stdio: ['ignore', 'pipe', 'pipe'] });
151
+ let out = '';
152
+ let err = '';
153
+ let settled = false;
154
+ const finish = (result) => { if (!settled) { settled = true; resolve(result); } };
155
+ const timer = setTimeout(() => {
156
+ child.kill();
157
+ finish({ ok: false, text: '', reason: `vision.py did not finish within ${timeoutMs} ms`, code: null, ms: Date.now() - started });
158
+ }, timeoutMs);
159
+
160
+ child.stdout.on('data', (c) => { out += c; });
161
+ child.stderr.on('data', (c) => { err += c; });
162
+ child.on('error', (error) => {
163
+ clearTimeout(timer);
164
+ // A missing interpreter is the most likely error here, and it must read as itself rather than as a
165
+ // generic failure -- the deploy that installs the script does not install python3.
166
+ finish({ ok: false, text: '', reason: error.code === 'ENOENT' ? `no ${python} on PATH` : `could not start ${python}: ${error.message}`, code: null, ms: Date.now() - started });
167
+ });
168
+ child.on('close', (code) => {
169
+ clearTimeout(timer);
170
+ const ms = Date.now() - started;
171
+ if (code === 0) {
172
+ const text = out.trim();
173
+ if (text === '') finish({ ok: false, text: '', reason: 'vision.py exited 0 but printed nothing', code, ms });
174
+ else finish({ ok: true, text, reason: null, code, ms });
175
+ return;
176
+ }
177
+ // The script's own words, not a paraphrase: it names the missing key env var, the bad path, or the HTTP
178
+ // status, and replacing that with "vision failed" throws away the only actionable part.
179
+ const detail = (err.trim() || out.trim() || `exit ${code}`).split(/\r?\n/).slice(-3).join(' | ');
180
+ finish({ ok: false, text: '', reason: detail, code, ms });
181
+ });
182
+ });
183
+ }
184
+
185
+ /**
186
+ * Is vision usable right now, and if not, why?
187
+ *
188
+ * ## Why the bridge calls this
189
+ *
190
+ * The bridge answers an image with a help message. **Without this it can only say "unsupported"**, which is the
191
+ * same sentence whether the skill is missing, the key is absent, or the interpreter is not installed -- and an
192
+ * operator reading it has no next step. With it, the message names the one thing that is missing.
193
+ *
194
+ * **This is the module's first production caller**, and it is deliberately the smallest useful one: a capability
195
+ * probe, not the analysis path. The orphan gate in `scripts/preflight.mjs` is what required a caller at all, and
196
+ * it was right to -- **a mechanism with no caller is indistinguishable from a finished feature**, which this
197
+ * repository has shipped four times.
198
+ *
199
+ * @param {{envFile?: string, scriptDir?: string, python?: string, env?: object}} [options]
200
+ * @returns {Promise<{ready: boolean, reason: string|null, provider: string|null}>}
201
+ */
202
+ export async function visionStatus(options = {}) {
203
+ const envFile = options.envFile ?? DEFAULT_ENV_FILE;
204
+ const scriptDir = options.scriptDir ?? DEFAULT_SKILL_DIR;
205
+ if (!existsSync(join(scriptDir, 'vision.py'))) {
206
+ return { ready: false, reason: `no vision.py at ${join(scriptDir, 'vision.py')}`, provider: null };
207
+ }
208
+ const { values, problem } = readEnvFile(envFile);
209
+ if (problem !== null) return { ready: false, reason: problem, provider: null };
210
+ const env = visionEnv(values, options.env ?? process.env);
211
+ const provider = env.VISION_PROVIDER ?? 'qwen';
212
+ if (!keyPresent(env)) {
213
+ return { ready: false, reason: `${envFile} has no key for provider "${provider}" (set ${KEY_FOR[provider] ?? 'VISION_API_KEY'})`, provider };
214
+ }
215
+ const python = options.python ?? 'python3';
216
+ // The interpreter is checked last because it is the one failure a deploy cannot fix on its own.
217
+ const found = await new Promise((resolve) => {
218
+ const child = spawn(python, ['--version'], { stdio: 'ignore' });
219
+ child.on('error', () => resolve(false));
220
+ child.on('close', (code) => resolve(code === 0));
221
+ });
222
+ if (!found) return { ready: false, reason: `no ${python} on PATH`, provider };
223
+ return { ready: true, reason: null, provider };
224
+ }
225
+
226
+ /**
227
+ * One sentence for a person who sent an image the bridge cannot hand to a model.
228
+ *
229
+ * @param {{envFile?: string, scriptDir?: string, python?: string, env?: object}} [options]
230
+ * @returns {Promise<string>}
231
+ */
232
+ export async function visionUnavailableNote(options = {}) {
233
+ const status = await visionStatus(options);
234
+ if (status.ready) return `vision is configured (${status.provider}) and can read an image on disk`;
235
+ return `vision is not available: ${status.reason}`;
236
+ }
237
+
238
+ /**
239
+ * The whole path, from a settings file to an answer.
240
+ *
241
+ * @param {{image: string, prompt?: string, command?: string, envFile?: string, scriptDir?: string, python?: string, env?: object, timeoutMs?: number}} options
242
+ */
243
+ export async function describeImage(options) {
244
+ const envFile = options.envFile ?? DEFAULT_ENV_FILE;
245
+ const { values, problem } = readEnvFile(envFile);
246
+ if (problem !== null) return { ok: false, text: '', reason: problem, code: null, ms: 0 };
247
+
248
+ const mode = modeOf(envFile);
249
+ if (mode !== null && (mode & 0o077) !== 0) {
250
+ // A settings file holding a credential that others can read is worth saying out loud, but it is not a
251
+ // refusal: the operator may have a reason, and refusing would make the tool unusable on a shared box.
252
+ return {
253
+ ok: false,
254
+ text: '',
255
+ reason: `${envFile} is mode ${mode.toString(8)}; a file holding a key should be 0600 (chmod 600 ${envFile})`,
256
+ code: null,
257
+ ms: 0,
258
+ };
259
+ }
260
+
261
+ const env = visionEnv(values, options.env ?? process.env);
262
+ if (!keyPresent(env)) {
263
+ const provider = env.VISION_PROVIDER ?? 'qwen';
264
+ return { ok: false, text: '', reason: `no key for provider "${provider}": set ${KEY_FOR[provider] ?? 'VISION_API_KEY'} in ${envFile}`, code: null, ms: 0 };
265
+ }
266
+ return analyze({ ...options, env });
267
+ }
@@ -208,7 +208,7 @@ const MAX_STAGES = 16;
208
208
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
209
209
  * overrides it at runtime for tests and packagers.
210
210
  */
211
- const PLUGIN_VERSION = '0.56.0';
211
+ const PLUGIN_VERSION = '0.57.1';
212
212
 
213
213
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
214
214
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;