@alotop/dsh-matlab-bridge 0.1.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/LICENSE +29 -0
- package/README.md +263 -0
- package/README.zh-CN.md +177 -0
- package/cordis.patch.yml +14 -0
- package/package.json +58 -0
- package/python/mfiles/dsh_evalbase.m +31 -0
- package/python/mfiles/dsh_figure_info.m +29 -0
- package/python/mfiles/dsh_figure_save.m +37 -0
- package/python/ml_driver.py +419 -0
- package/python/selftest.py +244 -0
- package/scripts/run-selftest.mjs +61 -0
- package/scripts/setup-engine.mjs +242 -0
- package/src/plugin.mjs +511 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 alotop
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
This package does not redistribute any MathWorks code. The MATLAB Engine for
|
|
26
|
+
Python runtime is laid out at install time by `scripts/setup-engine.mjs`, which
|
|
27
|
+
copies it out of a MATLAB installation the user already has and is licensed to
|
|
28
|
+
use. That generated copy lives in `python/pylibs/` and is neither committed to
|
|
29
|
+
this repository nor published to npm.
|
package/README.md
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# @alotop/dsh-matlab-bridge
|
|
2
|
+
|
|
3
|
+
Run and interactively debug MATLAB code from a [DSH](https://github.com/deepseek-ai) session,
|
|
4
|
+
through a persistent MATLAB Engine session.
|
|
5
|
+
|
|
6
|
+
[](https://www.npmjs.com/package/@alotop/dsh-matlab-bridge)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
9
|
+
[中文文档](README.zh-CN.md)
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Why this exists
|
|
14
|
+
|
|
15
|
+
MATLAB cannot start under DSH's default `workspace-write` file sandbox. It writes
|
|
16
|
+
outside the workspace during startup (preferences, licence cache) and dies with:
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
Fatal Startup Error: System error: File system inconsistency
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Relocating its preference directory does not help. Driving `matlab -batch`
|
|
23
|
+
through a shell therefore costs one sandbox escalation **per call**.
|
|
24
|
+
|
|
25
|
+
This plugin spawns its driver through the unconfined `ctx.subprocess` seam
|
|
26
|
+
instead, so that cost is paid once — and it keeps a MATLAB Engine session alive,
|
|
27
|
+
which is what makes debugging possible at all.
|
|
28
|
+
|
|
29
|
+
### Why a persistent session, not `matlab -batch`
|
|
30
|
+
|
|
31
|
+
`matlab -batch` pays a ~20 second cold start on every call and keeps nothing: no
|
|
32
|
+
variables, no open figures, no breakpoints. It also **cannot single-step**,
|
|
33
|
+
because a breakpoint blocks MATLAB's own command loop. Stepping needs an
|
|
34
|
+
out-of-band evaluator, which is exactly what the official MATLAB Engine API
|
|
35
|
+
provides.
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
DSH session ──ctx.subprocess.spawn──▶ python ml_driver.py
|
|
39
|
+
│ matlab.engine
|
|
40
|
+
▼
|
|
41
|
+
persistent, debuggable MATLAB
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Requirements
|
|
45
|
+
|
|
46
|
+
| | |
|
|
47
|
+
|---|---|
|
|
48
|
+
| MATLAB | R2020a or newer (for `exportgraphics`). Any standard install — the Python engine under `extern/engines/python` is what this uses. |
|
|
49
|
+
| Python | 3.9 – 3.13 on `PATH` |
|
|
50
|
+
| Node.js | 20 or newer |
|
|
51
|
+
|
|
52
|
+
The bundled engine advertises Python 3.9–3.12, but it ships a stable-ABI
|
|
53
|
+
(`abi3`) extension module that loads and runs correctly on 3.13 as well.
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
# 1. install the package into your DSH profile
|
|
59
|
+
dsh plugin --profile <profile> add @alotop/dsh-matlab-bridge
|
|
60
|
+
|
|
61
|
+
# 2. lay out the MATLAB Engine runtime inside that installed copy
|
|
62
|
+
node "<profile>/node_modules/@alotop/dsh-matlab-bridge/scripts/setup-engine.mjs"
|
|
63
|
+
|
|
64
|
+
# 3. restart DSH so the new bundle layer is composed
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Step 2 copies the engine out of the MATLAB you already have and generates the
|
|
68
|
+
`_arch.txt` file the engine needs to find MATLAB's native libraries. Nothing is
|
|
69
|
+
downloaded, and no MathWorks code is redistributed — see [LICENSE](LICENSE).
|
|
70
|
+
|
|
71
|
+
**Run step 2 against the installed copy, not a checkout.** The script writes to
|
|
72
|
+
`python/pylibs` beside itself, and the plugin loads the runtime from its own
|
|
73
|
+
installation — laying it out anywhere else leaves the installed copy empty. The
|
|
74
|
+
same copy also exposes the command as
|
|
75
|
+
`<profile>/node_modules/.bin/dsh-matlab-bridge-setup`.
|
|
76
|
+
|
|
77
|
+
### Why `dsh.bundle` is declared
|
|
78
|
+
|
|
79
|
+
`dsh plugin add` records a package as a **profile layer** only when its
|
|
80
|
+
`package.json` declares `dsh.bundle`. This package points that at
|
|
81
|
+
[`cordis.patch.yml`](cordis.patch.yml), which inserts the `matlab-bridge` row.
|
|
82
|
+
Without it the install still succeeds but nothing is registered, and DSH says so:
|
|
83
|
+
*declares no dsh.bundle — installed as a plain dependency, not a profile layer*.
|
|
84
|
+
|
|
85
|
+
### Alternative: a preset row
|
|
86
|
+
|
|
87
|
+
The same plugin can be composed from an agent preset instead of a profile
|
|
88
|
+
bundle. The row publishes no service and registers tools only, so it sits at the
|
|
89
|
+
top level of a preset and needs no `isolate` realm.
|
|
90
|
+
|
|
91
|
+
```yaml
|
|
92
|
+
- id: matlab-bridge
|
|
93
|
+
name: '@alotop/dsh-matlab-bridge'
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Standalone, or from a checkout
|
|
97
|
+
|
|
98
|
+
For developing the bridge itself, or before it is on npm:
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
npm pack # -> alotop-dsh-matlab-bridge-<version>.tgz
|
|
102
|
+
npx ./alotop-dsh-matlab-bridge-<version>.tgz # run setup straight from the tarball
|
|
103
|
+
npm install -g ./alotop-dsh-matlab-bridge-<version>.tgz # or install it globally
|
|
104
|
+
npm install --no-save ./alotop-dsh-matlab-bridge-<version>.tgz # or into a project
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Pass `--no-save` when you only want to try the tarball; without it npm records a
|
|
108
|
+
`file:` dependency on the tarball in that project's `package.json`.
|
|
109
|
+
|
|
110
|
+
There is only **one** package. `dsh-matlab-bridge-setup` is a `bin` entry of it,
|
|
111
|
+
not a separate package — `npx @alotop/dsh-matlab-bridge` resolves to that
|
|
112
|
+
executable because it is the package's only one.
|
|
113
|
+
|
|
114
|
+
### Row options
|
|
115
|
+
|
|
116
|
+
All optional; the defaults need no configuration on a normal machine.
|
|
117
|
+
|
|
118
|
+
```yaml
|
|
119
|
+
- id: matlab-bridge
|
|
120
|
+
name: '@alotop/dsh-matlab-bridge'
|
|
121
|
+
config:
|
|
122
|
+
pythonPath: python3.12 # default: first of python3, python on PATH
|
|
123
|
+
workDir: /path/to/project # default: the calling session's cwd
|
|
124
|
+
figureDir: /tmp/figures # default: <workDir>/.matlab-figures
|
|
125
|
+
timeoutMs: 180000 # default: 180000
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Tools
|
|
129
|
+
|
|
130
|
+
### `matlab_run`
|
|
131
|
+
|
|
132
|
+
Runs MATLAB code in the persistent session and returns its command-window
|
|
133
|
+
output. **Variables, figures and loaded data persist across calls.** Print with
|
|
134
|
+
`disp`/`fprintf`, or pass a single bare expression to have its value shown. On
|
|
135
|
+
failure the MATLAB error message and error stack come back.
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
matlab_run: A = magic(4); disp(trace(A))
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### `matlab_debug`
|
|
142
|
+
|
|
143
|
+
Drives the MATLAB debugger. Actions: `break`, `breakError`, `clearBreaks`,
|
|
144
|
+
`run`, `status`, `stack`, `vars`, `get`, `eval`, `step`, `stepIn`, `stepOut`,
|
|
145
|
+
`continue`, `quit`, `finish`.
|
|
146
|
+
|
|
147
|
+
A worked session:
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
matlab_debug action=break file=myfunc line=12 → ok
|
|
151
|
+
matlab_debug action=run code="myfunc(data)" → state: paused
|
|
152
|
+
matlab_debug action=vars → the paused frame's locals
|
|
153
|
+
matlab_debug action=get name=startIndex → 7
|
|
154
|
+
matlab_debug action=eval code="n - k + 1" → 8
|
|
155
|
+
matlab_debug action=step → state: paused (next line)
|
|
156
|
+
matlab_debug action=continue → state: completed
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`eval` runs an expression inside the paused frame, so a hypothesis about a live
|
|
160
|
+
local can be confirmed without editing the file. A breakpoint on a comment or
|
|
161
|
+
blank line binds to the next executable line, exactly as in the MATLAB editor.
|
|
162
|
+
|
|
163
|
+
### `matlab_figure`
|
|
164
|
+
|
|
165
|
+
Inspects and exports figures. `list` reports open figures, `save` writes each
|
|
166
|
+
one to a 150 DPI PNG and returns the paths, `close` closes one or all.
|
|
167
|
+
|
|
168
|
+
Plot first with `matlab_run`, export afterwards — figures stay open in the
|
|
169
|
+
session. **Read the exported PNGs back with `read_image`;** a path alone shows
|
|
170
|
+
nothing.
|
|
171
|
+
|
|
172
|
+
### `matlab_session`
|
|
173
|
+
|
|
174
|
+
`start`, `status`, `stop`. MATLAB takes tens of seconds to start, so the session
|
|
175
|
+
stays warm and is reused. `status` reports the package path, whether the driver
|
|
176
|
+
is running, and whether the engine runtime has been laid out.
|
|
177
|
+
|
|
178
|
+
## How it works
|
|
179
|
+
|
|
180
|
+
| File | Role |
|
|
181
|
+
|---|---|
|
|
182
|
+
| `src/plugin.mjs` | The Cordis plugin: locates its own files, owns the driver process, registers the tools |
|
|
183
|
+
| `python/ml_driver.py` | Persistent driver. Newline-delimited JSON on stdin/stdout, owning one MATLAB Engine session |
|
|
184
|
+
| `python/mfiles/dsh_evalbase.m` | Evaluates in the base workspace and reproduces command-window echo semantics |
|
|
185
|
+
| `python/mfiles/dsh_figure_*.m` | List and export figures as JSON |
|
|
186
|
+
| `scripts/setup-engine.mjs` | Lays out the engine runtime from a local MATLAB |
|
|
187
|
+
| `python/selftest.py` | End-to-end self-test against a real MATLAB |
|
|
188
|
+
|
|
189
|
+
### The `@@DSH:` protocol prefix
|
|
190
|
+
|
|
191
|
+
The MATLAB Engine forwards the MATLAB command window to the driver's stdout, so
|
|
192
|
+
a bare JSON protocol would race with MATLAB's own output. Every protocol line is
|
|
193
|
+
prefixed with `@@DSH:`; every unprefixed line is MATLAB chatter and is surfaced
|
|
194
|
+
as diagnostics rather than parsed.
|
|
195
|
+
|
|
196
|
+
### Command-window echo semantics
|
|
197
|
+
|
|
198
|
+
`dsh_evalbase.m` calls `evalc('evalin(''base'', code)')` and deliberately asks
|
|
199
|
+
`evalin` for **no output argument**. That is what makes capture match the command
|
|
200
|
+
window: an assignment echoes `x = 41`, a bare expression echoes `ans = 42`, and a
|
|
201
|
+
statement with no value prints nothing. Asking for an output argument suppresses
|
|
202
|
+
all three, and guessing from the source text whether the code is an expression
|
|
203
|
+
misclassifies command syntax such as `dbstop in f at 4` and output-less calls
|
|
204
|
+
such as `disp('hi')`.
|
|
205
|
+
|
|
206
|
+
## Development
|
|
207
|
+
|
|
208
|
+
```sh
|
|
209
|
+
git clone https://github.com/alotop/dsh-matlab-bridge.git
|
|
210
|
+
cd dsh-matlab-bridge
|
|
211
|
+
npm run setup # lay out the engine runtime from your local MATLAB
|
|
212
|
+
npm run selftest # end-to-end checks against a real MATLAB (starts one)
|
|
213
|
+
npm run check # syntax-check the plugin and scripts
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
`npm run selftest` exercises the protocol, output capture, error reporting,
|
|
217
|
+
figure export and the full breakpoint stepping sequence. A failure there is a
|
|
218
|
+
driver bug, not a plugin or transport bug, which keeps the fault domain small.
|
|
219
|
+
|
|
220
|
+
## Releasing
|
|
221
|
+
|
|
222
|
+
Publishing runs only on a version tag, and only
|
|
223
|
+
[`.github/workflows/release.yml`](.github/workflows/release.yml) can do it:
|
|
224
|
+
|
|
225
|
+
```sh
|
|
226
|
+
npm version patch # or minor / major; commits and tags
|
|
227
|
+
git push --follow-tags
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
The workflow refuses to publish when the tag and `package.json` version
|
|
231
|
+
disagree, and refuses to publish a tarball containing the engine runtime.
|
|
232
|
+
|
|
233
|
+
Authentication is **OIDC Trusted Publishing**, so no npm token is stored in the
|
|
234
|
+
repository. One-time setup on npmjs.com:
|
|
235
|
+
|
|
236
|
+
1. The package must exist — do a first manual `npm publish` from a checkout.
|
|
237
|
+
2. Package → Settings → **Trusted Publisher** → GitHub Actions, with
|
|
238
|
+
repository `alotop/dsh-matlab-bridge` and workflow `release.yml`.
|
|
239
|
+
|
|
240
|
+
After that, `id-token: write` in the workflow is the only credential needed, and
|
|
241
|
+
every published version carries a signed provenance attestation.
|
|
242
|
+
|
|
243
|
+
## Limitations
|
|
244
|
+
|
|
245
|
+
- **Figures are delivered as files, not inline images.** `matlab_figure` returns
|
|
246
|
+
PNG paths. An inline image block would have to go through the attachments
|
|
247
|
+
service to obtain an attachment reference; a path plus `read_image` already
|
|
248
|
+
makes the plot visible and carries no service-contract dependency.
|
|
249
|
+
- **A hard-killed driver can leave MATLAB behind.** The driver quits the engine
|
|
250
|
+
on `atexit`, which does not run on a hard kill. Use `matlab_session` with
|
|
251
|
+
`action="stop"` to shut down cleanly.
|
|
252
|
+
- **Queries queue while MATLAB is busy.** The engine serializes requests on one
|
|
253
|
+
thread, so a `dbstack` probe issued while a background run is mid-execution
|
|
254
|
+
blocks until that run yields. `run` checks completion before probing the stack
|
|
255
|
+
to avoid the worst of it.
|
|
256
|
+
- **Single-step debugging is Windows-verified.** The engine layout logic handles
|
|
257
|
+
`glnxa64` and `maca64`/`maci64`, but only Windows has been exercised
|
|
258
|
+
end-to-end. Reports from other platforms are welcome.
|
|
259
|
+
|
|
260
|
+
## License
|
|
261
|
+
|
|
262
|
+
[MIT](LICENSE). This package does not redistribute MathWorks code; see the note
|
|
263
|
+
at the end of the license file.
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# @alotop/dsh-matlab-bridge
|
|
2
|
+
|
|
3
|
+
在 [DSH](https://github.com/deepseek-ai) 会话里**运行和交互式单步调试** MATLAB 代码,底层是一个常驻的 MATLAB Engine 会话。
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@alotop/dsh-matlab-bridge)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
[English](README.md)
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## 为什么需要它
|
|
13
|
+
|
|
14
|
+
MATLAB 在 DSH 默认的 `workspace-write` 文件沙箱下**根本无法启动**。它在启动时会写工作区之外的位置(偏好设置、许可缓存),然后直接崩掉:
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
Fatal Startup Error: System error: File system inconsistency
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
把偏好目录重定向到可写位置**也没用**。所以用 shell 去驱动 `matlab -batch`,代价是**每一次调用都要一次沙箱提权**。
|
|
21
|
+
|
|
22
|
+
本插件改为通过非受限的 `ctx.subprocess` 通道启动驱动进程,这笔代价只需付一次——而且它保持一个 MATLAB Engine 会话存活,这正是一切调试能力的前提。
|
|
23
|
+
|
|
24
|
+
### 为什么必须常驻会话,而不是 `matlab -batch`
|
|
25
|
+
|
|
26
|
+
`matlab -batch` 每次调用都要**约 20 秒冷启动**,而且什么都不保留:变量、打开的图形、断点全部丢失。更关键的是它**无法单步调试**——断点命中时 MATLAB 自己的命令循环被阻塞,要发出 `dbstep` 必须有带外求值器,而官方 MATLAB Engine API 提供的正是这个。
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
DSH 会话 ──ctx.subprocess.spawn──▶ python ml_driver.py
|
|
30
|
+
│ matlab.engine
|
|
31
|
+
▼
|
|
32
|
+
常驻、可调试的 MATLAB
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## 环境要求
|
|
36
|
+
|
|
37
|
+
| | |
|
|
38
|
+
|---|---|
|
|
39
|
+
| MATLAB | R2020a 或更新(需要 `exportgraphics`)。任意标准安装即可,用的是它自带的 `extern/engines/python`。 |
|
|
40
|
+
| Python | 3.9 – 3.13,需在 `PATH` 上 |
|
|
41
|
+
| Node.js | 20 或更新 |
|
|
42
|
+
|
|
43
|
+
引擎自带的声明是支持 Python 3.9–3.12,但它附带的是**稳定 ABI(`abi3`)**扩展模块,实测在 3.13 上也能正常装载和运行。
|
|
44
|
+
|
|
45
|
+
## 安装
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
# 1. 把包装进你的 DSH profile
|
|
49
|
+
dsh plugin --profile <profile> add @alotop/dsh-matlab-bridge
|
|
50
|
+
|
|
51
|
+
# 2. 在「已安装的那份」里铺开 Engine 运行库
|
|
52
|
+
node "<profile>/node_modules/@alotop/dsh-matlab-bridge/scripts/setup-engine.mjs"
|
|
53
|
+
|
|
54
|
+
# 3. 重启 DSH,让新的 bundle 层被组装进去
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
第 2 步会从你**已经装好的** MATLAB 里复制引擎,并生成引擎定位原生库所需的 `_arch.txt`。不下载任何东西,也不再分发任何 MathWorks 代码——详见 [LICENSE](LICENSE)。
|
|
58
|
+
|
|
59
|
+
**第 2 步必须针对「已安装的那份」执行,不要跑仓库里的检出。** 脚本会写到它自己旁边的 `python/pylibs`,而插件是从自己的安装位置加载运行库的——铺到别处,已安装的那份仍然是空的。同一份安装也提供 `<profile>/node_modules/.bin/dsh-matlab-bridge-setup` 这个命令。
|
|
60
|
+
|
|
61
|
+
### 为什么要声明 `dsh.bundle`
|
|
62
|
+
|
|
63
|
+
`dsh plugin add` 只有在包的 `package.json` 声明了 `dsh.bundle` 时,才会把它记为 **profile layer**。本包把它指向 [`cordis.patch.yml`](cordis.patch.yml),由后者插入 `matlab-bridge` 这一行。没有它,安装照样成功但**什么都不会注册**,DSH 也会明确提示:*declares no dsh.bundle — installed as a plain dependency, not a profile layer*。
|
|
64
|
+
|
|
65
|
+
### 备选:写成 preset 行
|
|
66
|
+
|
|
67
|
+
同一个插件也可以不用 profile bundle,而是从 agent preset 组装。该行只注册工具、不发布服务,因此直接放在 preset 顶层即可,不需要 `isolate` realm。
|
|
68
|
+
|
|
69
|
+
```yaml
|
|
70
|
+
- id: matlab-bridge
|
|
71
|
+
name: '@alotop/dsh-matlab-bridge'
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### 独立使用,或从检出安装
|
|
75
|
+
|
|
76
|
+
用于开发这个桥接本身,或者包还没发到 npm 之前:
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
npm pack # 得到 alotop-dsh-matlab-bridge-<版本>.tgz
|
|
80
|
+
npx ./alotop-dsh-matlab-bridge-<版本>.tgz # 直接从 tarball 跑安装命令
|
|
81
|
+
npm install -g ./alotop-dsh-matlab-bridge-<版本>.tgz # 或全局安装
|
|
82
|
+
npm install --no-save ./alotop-dsh-matlab-bridge-<版本>.tgz # 或装进某个工程
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
只想试一下 tarball 时记得加 `--no-save`;否则 npm 会在那个工程的 `package.json` 里记下一条指向 tarball 的 `file:` 依赖。
|
|
86
|
+
|
|
87
|
+
**只有一个包。** `dsh-matlab-bridge-setup` 是它的一个 `bin` 入口,不是独立包——`npx @alotop/dsh-matlab-bridge` 之所以能跑起它,是因为它是该包唯一的可执行文件。
|
|
88
|
+
|
|
89
|
+
### 行配置
|
|
90
|
+
|
|
91
|
+
全部可选,正常机器上无需任何配置。
|
|
92
|
+
|
|
93
|
+
```yaml
|
|
94
|
+
- id: matlab-bridge
|
|
95
|
+
name: '@alotop/dsh-matlab-bridge'
|
|
96
|
+
config:
|
|
97
|
+
pythonPath: python3.12 # 默认:PATH 上第一个 python3 / python
|
|
98
|
+
workDir: /path/to/project # 默认:调用方会话的 cwd
|
|
99
|
+
figureDir: /tmp/figures # 默认:<workDir>/.matlab-figures
|
|
100
|
+
timeoutMs: 180000 # 默认:180000
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## 工具
|
|
104
|
+
|
|
105
|
+
### `matlab_run`
|
|
106
|
+
|
|
107
|
+
在常驻会话里运行 MATLAB 代码,返回命令窗口输出。**变量、图形、数据跨调用保留。** 用 `disp`/`fprintf` 打印,或者传入单个裸表达式让它显示值。失败时返回 MATLAB 错误信息与错误栈。
|
|
108
|
+
|
|
109
|
+
### `matlab_debug`
|
|
110
|
+
|
|
111
|
+
驱动 MATLAB 调试器。动作:`break`、`breakError`、`clearBreaks`、`run`、`status`、`stack`、`vars`、`get`、`eval`、`step`、`stepIn`、`stepOut`、`continue`、`quit`、`finish`。
|
|
112
|
+
|
|
113
|
+
一次真实会话:
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
matlab_debug action=break file=myfunc line=12 → ok
|
|
117
|
+
matlab_debug action=run code="myfunc(data)" → state: paused
|
|
118
|
+
matlab_debug action=vars → 暂停帧的局部变量
|
|
119
|
+
matlab_debug action=get name=startIndex → 7
|
|
120
|
+
matlab_debug action=eval code="n - k + 1" → 8
|
|
121
|
+
matlab_debug action=step → state: paused(下一行)
|
|
122
|
+
matlab_debug action=continue → state: completed
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`eval` 在**暂停帧内部**求值,所以可以在不改文件的前提下验证对某个活变量的推断。断点打在注释或空行上会绑定到下一个可执行行,与 MATLAB 编辑器行为一致。
|
|
126
|
+
|
|
127
|
+
### `matlab_figure`
|
|
128
|
+
|
|
129
|
+
检查与导出图形。`list` 列出打开的 figure,`save` 把每张图导出为 150 DPI 的 PNG 并返回路径,`close` 关闭一张或全部。
|
|
130
|
+
|
|
131
|
+
先用 `matlab_run` 画图,再导出——figure 留在会话里。**拿到路径后要用 `read_image` 把 PNG 读回来**;只拿路径等于没看图。
|
|
132
|
+
|
|
133
|
+
### `matlab_session`
|
|
134
|
+
|
|
135
|
+
`start`、`status`、`stop`。MATLAB 启动要几十秒,所以会话保持热态并复用。`status` 会报告包路径、驱动是否在跑、以及引擎运行库是否已经铺开。
|
|
136
|
+
|
|
137
|
+
## 工作原理
|
|
138
|
+
|
|
139
|
+
| 文件 | 作用 |
|
|
140
|
+
|---|---|
|
|
141
|
+
| `src/plugin.mjs` | Cordis 插件:定位自身文件、持有驱动进程、注册工具 |
|
|
142
|
+
| `python/ml_driver.py` | 常驻驱动。stdin/stdout 上是行分隔 JSON,持有一个 MATLAB Engine 会话 |
|
|
143
|
+
| `python/mfiles/dsh_evalbase.m` | 在 base 工作区求值,复现命令窗口的回显语义 |
|
|
144
|
+
| `python/mfiles/dsh_figure_*.m` | 以 JSON 形式列举与导出 figure |
|
|
145
|
+
| `scripts/setup-engine.mjs` | 从本地 MATLAB 铺开引擎运行库 |
|
|
146
|
+
| `python/selftest.py` | 对真实 MATLAB 的端到端自检 |
|
|
147
|
+
|
|
148
|
+
### 为什么协议要带 `@@DSH:` 前缀
|
|
149
|
+
|
|
150
|
+
MATLAB Engine 会把 MATLAB 命令窗口输出转发到驱动的 stdout,裸 JSON 协议会和这些输出抢同一个流。因此每一行协议都带 `@@DSH:` 前缀,不带前缀的行一律视为 MATLAB 输出(诊断信息),而不是去解析它。
|
|
151
|
+
|
|
152
|
+
### 命令窗口回显语义
|
|
153
|
+
|
|
154
|
+
`dsh_evalbase.m` 调用 `evalc('evalin(''base'', code)')`,并且**刻意不给 `evalin` 输出参数**。这才让捕获行为与命令窗口一致:赋值回显 `x = 41`,裸表达式回显 `ans = 42`,无值语句不输出。反过来,去要输出参数会把这三者全部抑制;而靠源码文本猜"是不是表达式"则会误判命令语法(如 `dbstop in f at 4`)和无输出参数的调用(如 `disp('hi')`)。
|
|
155
|
+
|
|
156
|
+
## 开发
|
|
157
|
+
|
|
158
|
+
```sh
|
|
159
|
+
git clone https://github.com/alotop/dsh-matlab-bridge.git
|
|
160
|
+
cd dsh-matlab-bridge
|
|
161
|
+
npm run setup # 从本地 MATLAB 铺开引擎运行库
|
|
162
|
+
npm run selftest # 对真实 MATLAB 的端到端检查(会启动一个)
|
|
163
|
+
npm run check # 语法检查插件与脚本
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`npm run selftest` 覆盖协议、输出捕获、错误上报、图形导出,以及完整的断点单步链路。它失败 = 驱动有问题,而不是插件或传输层的问题,故障域因此很小。
|
|
167
|
+
|
|
168
|
+
## 已知限制
|
|
169
|
+
|
|
170
|
+
- **图形以文件交付,不是内联图像。** `matlab_figure` 返回 PNG 路径。工具结果理论上可以携带内联图像块,但那要走附件服务换取引用;路径加 `read_image` 已经能让图可见,且不依赖任何服务契约。
|
|
171
|
+
- **驱动被强杀可能遗留 MATLAB 进程。** 驱动在 `atexit` 里退出引擎,但被硬终止时 `atexit` 不会执行。用 `matlab_session` 的 `action="stop"` 正常收尾。
|
|
172
|
+
- **MATLAB 忙时查询会排队。** 引擎在单线程上串行处理请求,所以后台运行未命中时发出的 `dbstack` 探测会阻塞到该调用让出控制权。`run` 已改为先查完成状态再探栈,规避了最糟的情况。
|
|
173
|
+
- **单步调试目前只在 Windows 上验证过。** 引擎铺开逻辑支持 `glnxa64` 与 `maca64`/`maci64`,但只有 Windows 跑过完整的端到端。欢迎其他平台的反馈。
|
|
174
|
+
|
|
175
|
+
## 许可
|
|
176
|
+
|
|
177
|
+
[MIT](LICENSE)。本包不再分发任何 MathWorks 代码;见许可文件末尾的说明。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# matlab-bridge bundle layer: register the tool plugin row.
|
|
2
|
+
#
|
|
3
|
+
# `dsh plugin --profile <profile> add @alotop/dsh-matlab-bridge` installs this
|
|
4
|
+
# package and composes this row into the profile. Declaring it here instead of
|
|
5
|
+
# asking the user to paste the row into a preset is what makes the package
|
|
6
|
+
# self-contained and lets `dsh plugin add` activate it on its own -- without a
|
|
7
|
+
# dsh.bundle in package.json the install is recorded as a plain dependency and
|
|
8
|
+
# nothing is registered.
|
|
9
|
+
#
|
|
10
|
+
# The row publishes no service, only registers tools, so it sits loose and needs
|
|
11
|
+
# no `isolate` realm.
|
|
12
|
+
- insert:
|
|
13
|
+
- id: matlab-bridge
|
|
14
|
+
name: '@alotop/dsh-matlab-bridge'
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@alotop/dsh-matlab-bridge",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Run and interactively debug MATLAB code from a DSH session, through a persistent MATLAB Engine session.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"dsh",
|
|
7
|
+
"dsh-plugin",
|
|
8
|
+
"cordis",
|
|
9
|
+
"matlab",
|
|
10
|
+
"debugger",
|
|
11
|
+
"matlab-engine"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "alotop",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/plugin.mjs"
|
|
18
|
+
},
|
|
19
|
+
"bin": {
|
|
20
|
+
"dsh-matlab-bridge-setup": "scripts/setup-engine.mjs"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"src",
|
|
24
|
+
"scripts",
|
|
25
|
+
"cordis.patch.yml",
|
|
26
|
+
"python/ml_driver.py",
|
|
27
|
+
"python/mfiles",
|
|
28
|
+
"python/selftest.py",
|
|
29
|
+
"README.md",
|
|
30
|
+
"README.zh-CN.md",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"setup": "node scripts/setup-engine.mjs",
|
|
38
|
+
"selftest": "node scripts/run-selftest.mjs",
|
|
39
|
+
"check": "node --check src/plugin.mjs && node --check scripts/setup-engine.mjs"
|
|
40
|
+
},
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "git+https://github.com/alotop/dsh-matlab-bridge.git"
|
|
44
|
+
},
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/alotop/dsh-matlab-bridge/issues"
|
|
47
|
+
},
|
|
48
|
+
"homepage": "https://github.com/alotop/dsh-matlab-bridge#readme",
|
|
49
|
+
"dsh": {
|
|
50
|
+
"bundle": {
|
|
51
|
+
"patch": "./cordis.patch.yml"
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public",
|
|
56
|
+
"provenance": true
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
function r = dsh_evalbase(code)
|
|
2
|
+
%DSH_EVALBASE Execute CODE in the base workspace, capturing output and errors.
|
|
3
|
+
%
|
|
4
|
+
% Evaluation is forced into the base workspace via EVALIN so variables persist
|
|
5
|
+
% across calls; a function-local EVAL would discard them as soon as the call
|
|
6
|
+
% returned.
|
|
7
|
+
%
|
|
8
|
+
% EVALIN is deliberately asked for NO output argument, and that is what makes
|
|
9
|
+
% the capture match the command window: an assignment echoes `x = 41`, a bare
|
|
10
|
+
% expression echoes `ans = 42`, and a statement with no value prints nothing.
|
|
11
|
+
% Requesting an output argument instead suppresses all three, which is why no
|
|
12
|
+
% caller should try to reconstruct the echo by inspecting the value afterwards.
|
|
13
|
+
%
|
|
14
|
+
% Returns a struct:
|
|
15
|
+
% out - captured command-window text
|
|
16
|
+
% err - error message, empty on success
|
|
17
|
+
% stack - formatted error stack, empty when unavailable
|
|
18
|
+
|
|
19
|
+
r = struct('out', '', 'err', '', 'stack', '');
|
|
20
|
+
|
|
21
|
+
try
|
|
22
|
+
r.out = evalc('evalin(''base'', code)');
|
|
23
|
+
catch err
|
|
24
|
+
r.err = err.message;
|
|
25
|
+
if ~isempty(err.stack)
|
|
26
|
+
parts = arrayfun(@(s) sprintf('%s (line %d)', s.name, s.line), ...
|
|
27
|
+
err.stack, 'UniformOutput', false);
|
|
28
|
+
r.stack = strjoin(parts, newline);
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
function payload = dsh_figure_info()
|
|
2
|
+
%DSH_FIGURE_INFO Describe every open figure, in creation order, as JSON.
|
|
3
|
+
%
|
|
4
|
+
% Returning JSON text rather than a struct array keeps the Python side free of
|
|
5
|
+
% MATLAB struct-array marshalling rules: the driver only ever calls
|
|
6
|
+
% `json.loads` on this.
|
|
7
|
+
%
|
|
8
|
+
% An empty array is a legitimate answer (no figures are open), not an error.
|
|
9
|
+
|
|
10
|
+
figs = flipud(findobj('Type', 'figure'));
|
|
11
|
+
|
|
12
|
+
info = struct('number', {}, 'name', {}, 'visible', {});
|
|
13
|
+
for k = 1:numel(figs)
|
|
14
|
+
f = figs(k);
|
|
15
|
+
try
|
|
16
|
+
name = char(f.Name);
|
|
17
|
+
catch
|
|
18
|
+
name = '';
|
|
19
|
+
end
|
|
20
|
+
try
|
|
21
|
+
visible = char(f.Visible);
|
|
22
|
+
catch
|
|
23
|
+
visible = 'unknown';
|
|
24
|
+
end
|
|
25
|
+
info(end + 1) = struct('number', double(f.Number), 'name', name, 'visible', visible);
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
payload = jsonencode(info);
|
|
29
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
function payload = dsh_figure_save(dirPath, which)
|
|
2
|
+
%DSH_FIGURE_SAVE Export figures to PNG files and report where each landed, as JSON.
|
|
3
|
+
%
|
|
4
|
+
% WHICH selects the figures: 0 exports every open figure, a positive number
|
|
5
|
+
% exports just that figure. Each becomes figure-<N>.png under DIRPATH.
|
|
6
|
+
%
|
|
7
|
+
% One figure failing to export (an unsupported renderer, a zero-size canvas)
|
|
8
|
+
% must not lose the others, so failures are reported per figure in the `error`
|
|
9
|
+
% field rather than raised; the caller sees which ones worked.
|
|
10
|
+
%
|
|
11
|
+
% JSON text rather than a struct array, so the Python side needs no
|
|
12
|
+
% struct-array marshalling -- see dsh_figure_info for the same reasoning.
|
|
13
|
+
|
|
14
|
+
if ~exist(dirPath, 'dir')
|
|
15
|
+
mkdir(dirPath);
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
if which == 0
|
|
19
|
+
figs = flipud(findobj('Type', 'figure'));
|
|
20
|
+
else
|
|
21
|
+
figs = findobj('Type', 'figure', 'Number', which);
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
results = struct('number', {}, 'path', {}, 'error', {});
|
|
25
|
+
for k = 1:numel(figs)
|
|
26
|
+
f = figs(k);
|
|
27
|
+
file = fullfile(dirPath, sprintf('figure-%d.png', f.Number));
|
|
28
|
+
try
|
|
29
|
+
exportgraphics(f, file, 'Resolution', 150);
|
|
30
|
+
results(end + 1) = struct('number', double(f.Number), 'path', file, 'error', '');
|
|
31
|
+
catch err
|
|
32
|
+
results(end + 1) = struct('number', double(f.Number), 'path', '', 'error', err.message);
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
payload = jsonencode(results);
|
|
37
|
+
end
|