@twinsearth/w2m-dsh-plugin 0.55.0 → 0.57.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 +95 -0
- package/package.json +3 -3
- package/plugin.json +1 -1
- package/src/chatops/vision.mjs +267 -0
- package/src/plugin/tools.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,101 @@ 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.0] — 2026-10-15
|
|
8
|
+
|
|
9
|
+
**An image path becomes text evidence: the vision subprocess, and the credential path that does not touch argv.**
|
|
10
|
+
|
|
11
|
+
### What this is, and what it deliberately is not
|
|
12
|
+
|
|
13
|
+
`src/chatops/vision.mjs` runs the vendored `vision.py` as a subprocess. **It is not the WeChat integration** —
|
|
14
|
+
the bridge still answers an image with a help message. The two halves are kept apart because each has a different
|
|
15
|
+
failure mode and can be verified alone, **and this half is verifiable today**: give it a real image and it either
|
|
16
|
+
returns text or a named reason.
|
|
17
|
+
|
|
18
|
+
**Verified against the real endpoint, on this machine:** a generated 32×32 red PNG, `glm-4v-plus`, **1.77 s**,
|
|
19
|
+
reply `"红色。"` — and `prompt_tokens` 64 → 2374, so the image genuinely reached the model rather than being
|
|
20
|
+
ignored.
|
|
21
|
+
|
|
22
|
+
### The credential never enters `argv`
|
|
23
|
+
|
|
24
|
+
`vision.py` resolves its key as `--api-key` > `VISION_API_KEY` > the provider's own variable — read out of its
|
|
25
|
+
own source, not assumed. **Passing it on the command line would put a live key into `ps` output and into any
|
|
26
|
+
argument logging**, so this module only ever sets it **in the child's environment**, and the caller supplies it
|
|
27
|
+
from a file.
|
|
28
|
+
|
|
29
|
+
**The settings file is a parser, not a shell.** No expansion, no quoting rules, no `export` prefix: a value of
|
|
30
|
+
`$HOME` stays the four characters `$HOME`. **A settings file that is half a shell is a shell**, and the point of
|
|
31
|
+
this one is that it is read rather than executed.
|
|
32
|
+
|
|
33
|
+
### An error message I wrote that collapsed three causes into one
|
|
34
|
+
|
|
35
|
+
My first probe of the credential printed
|
|
36
|
+
`==> refused; the key or the model name is not accepted` for **any** non-2xx response. The actual response was:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
HTTP 400 code 1210 "图片输入格式/解析错误"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**That is an image-decoding failure — which is proof the credential was accepted**, because bigmodel answers
|
|
43
|
+
**401** for a bad key. **My hand-built base64 PNG was malformed, and I reported it as a credential problem.** One
|
|
44
|
+
conclusion for three different causes is the failure this repository keeps naming, and I committed it while
|
|
45
|
+
writing a file whose own header complains about that pattern.
|
|
46
|
+
|
|
47
|
+
The re-probe generates the PNG from `zlib.deflateSync` plus a hand-written CRC — **correct by construction** —
|
|
48
|
+
and splits the two questions: a text-only request isolates the key, and the image isolates the vision path. Both
|
|
49
|
+
returned 200.
|
|
50
|
+
|
|
51
|
+
### One ordering the code decided and the test got wrong
|
|
52
|
+
|
|
53
|
+
The module checks the image argument, then the script, then whether the image exists. My new assertion claimed a
|
|
54
|
+
non-existent image outranks a missing script — **it does not, and the code was right.** The assertion was
|
|
55
|
+
narrowed to the ordering that is actually guaranteed: **an empty argument is rejected before the installation is
|
|
56
|
+
consulted.**
|
|
57
|
+
|
|
58
|
+
**17 new tests, 0 failures**, and none of them needs a network or a key — every refusal path is asserted, and the
|
|
59
|
+
one thing not asserted is a real model answer, because a suite that needs a credential is a suite that skips
|
|
60
|
+
wherever the credential is absent.
|
|
61
|
+
|
|
62
|
+
## [0.56.0] — 2026-10-15
|
|
63
|
+
|
|
64
|
+
**The declared endpoint is now tested against the real binary, so a drifted port turns the pre-flight red.**
|
|
65
|
+
|
|
66
|
+
### The gap the sixteen transport tests left open
|
|
67
|
+
|
|
68
|
+
`mcp-http.test.mjs` drives `createRequestHandler` **in process**, with fake request and response objects. That
|
|
69
|
+
proves the framing, and it proves **nothing about the CLI path**: a wrong default port, a crash during startup,
|
|
70
|
+
an import cycle, or a `--http` flag that is never reached would all pass while `mcp.json` pointed at a dead URL.
|
|
71
|
+
|
|
72
|
+
**A wrong end of a declared address is not a unit-test-shaped bug.** It has to be dialled.
|
|
73
|
+
|
|
74
|
+
### What the new suite does differently
|
|
75
|
+
|
|
76
|
+
`test/mcp-http-live.test.mjs` **spawns `bin/w2m-mcp.mjs --http`** and speaks to **the URL read out of
|
|
77
|
+
`mcp.json`** — not a URL the test picks, because a healthy server on port 0 would prove nothing about `8766`.
|
|
78
|
+
|
|
79
|
+
It asserts the far end is reachable and speaking the protocol, and **deliberately does not assert a successful
|
|
80
|
+
tool result**: the relay needs an operator token the test does not carry, so **a `401` is the expected
|
|
81
|
+
outcome**. That refusal is *stronger* evidence than a mocked `200` — **it proves the request left this process,
|
|
82
|
+
crossed the binding, and was answered by the fleet's real relay**, which a mock could not distinguish from a
|
|
83
|
+
server that never dialled out.
|
|
84
|
+
|
|
85
|
+
**stdout is asserted empty.** The stdio binding owns stdout; if this path ever wrote a frame there, a client
|
|
86
|
+
launching both would see a corrupted stream and the symptom would be a hang rather than an error.
|
|
87
|
+
|
|
88
|
+
### Three levels of nesting, which is why this was worth checking
|
|
89
|
+
|
|
90
|
+
`pre-flight` → runs the declared suite as a child → which runs this suite → which spawns the server. **That
|
|
91
|
+
works**, and the run proved it rather than assuming it.
|
|
92
|
+
|
|
93
|
+
### The one failure it produced, and it was mine
|
|
94
|
+
|
|
95
|
+
The first run went red on `claims.test.mjs`: **`package.json` said 0.56.0 and the newest CHANGELOG entry said
|
|
96
|
+
0.55.0.** I bumped the version before writing the entry. **This project's own check caught my ordering mistake**
|
|
97
|
+
— which is the whole point of `claims` existing, and the second time this session that a version bump without an
|
|
98
|
+
entry has been the only red line in an otherwise green run.
|
|
99
|
+
|
|
100
|
+
**6 new tests, 0 failures.** Full suite 2324 in 87 files. **Pre-flight GREEN with FIFTEEN checks.**
|
|
101
|
+
|
|
7
102
|
## [0.55.0] — 2026-10-15
|
|
8
103
|
|
|
9
104
|
**A second transport binding — and `mcp.json` now declares both.**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.57.0",
|
|
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/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/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.
|
|
4
|
+
"version": "0.57.0",
|
|
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
|
+
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -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.
|
|
211
|
+
const PLUGIN_VERSION = '0.57.0';
|
|
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;
|