@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/src/plugin.mjs
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @alotop/dsh-matlab-bridge — run and interactively debug MATLAB code from a
|
|
3
|
+
* DSH session, through a persistent MATLAB Engine session.
|
|
4
|
+
*
|
|
5
|
+
* WHY A BRIDGE AT ALL
|
|
6
|
+
* MATLAB cannot start under DSH's workspace-write file sandbox: it writes
|
|
7
|
+
* outside the workspace during startup (preferences, licence cache) and dies
|
|
8
|
+
* with "Fatal Startup Error: System error: File system inconsistency".
|
|
9
|
+
* Relocating its preference directory does not help. Driving `matlab -batch`
|
|
10
|
+
* through a shell therefore costs one sandbox escalation per call.
|
|
11
|
+
*
|
|
12
|
+
* This plugin spawns a driver through the unconfined `ctx.subprocess` seam
|
|
13
|
+
* instead, so the escalation is paid once, and every `matlab_*` call runs
|
|
14
|
+
* without approval.
|
|
15
|
+
*
|
|
16
|
+
* WHY A PERSISTENT ENGINE, NOT `matlab -batch`
|
|
17
|
+
* `-batch` pays a ~20s cold start per call and keeps nothing: no variables, no
|
|
18
|
+
* figures, no breakpoints. It also cannot single-step, because a breakpoint
|
|
19
|
+
* blocks MATLAB's own command loop; stepping needs an out-of-band evaluator,
|
|
20
|
+
* which is exactly what the official MATLAB Engine API provides.
|
|
21
|
+
*
|
|
22
|
+
* DSH (this plugin) --ctx.subprocess.spawn--> python ml_driver.py
|
|
23
|
+
* | matlab.engine
|
|
24
|
+
* v persistent, debuggable MATLAB
|
|
25
|
+
*
|
|
26
|
+
* PROTOCOL
|
|
27
|
+
* The engine forwards the MATLAB command window to the driver's stdout, so the
|
|
28
|
+
* JSON protocol is line-prefixed with `@@DSH:`; every unprefixed line is MATLAB
|
|
29
|
+
* chatter and is surfaced as diagnostics rather than parsed.
|
|
30
|
+
*
|
|
31
|
+
* PATH RESOLUTION
|
|
32
|
+
* Everything the plugin needs ships inside this package, so nothing is
|
|
33
|
+
* hardcoded to one machine:
|
|
34
|
+
* - the driver and its MATLAB helpers are located relative to this module
|
|
35
|
+
* via `import.meta.url`;
|
|
36
|
+
* - the working directory defaults to the calling session's cwd;
|
|
37
|
+
* - the Python interpreter is discovered on PATH, or set explicitly with the
|
|
38
|
+
* row's `pythonPath` option.
|
|
39
|
+
*
|
|
40
|
+
* This row publishes NO service -- it only registers tools -- so it may sit
|
|
41
|
+
* loose in a preset and needs no `isolate` realm.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { existsSync } from 'node:fs'
|
|
45
|
+
import { dirname, join } from 'node:path'
|
|
46
|
+
import { fileURLToPath } from 'node:url'
|
|
47
|
+
|
|
48
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
49
|
+
export const name = 'matlab-bridge'
|
|
50
|
+
|
|
51
|
+
/** The subprocess seam and the tool registry must exist before registering. */
|
|
52
|
+
export const inject = ['subprocess', 'tools']
|
|
53
|
+
|
|
54
|
+
/** Package root, so the driver ships with the plugin instead of a fixed path. */
|
|
55
|
+
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
56
|
+
const DRIVER_PATH = join(PACKAGE_ROOT, 'python', 'ml_driver.py')
|
|
57
|
+
const PYLIBS_PATH = join(PACKAGE_ROOT, 'python', 'pylibs')
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Interpreter names to try, in order. `python3` first because a bare `python`
|
|
61
|
+
* on macOS and Linux may still be a Python 2 shim.
|
|
62
|
+
*/
|
|
63
|
+
const PYTHON_CANDIDATES = ['python3', 'python']
|
|
64
|
+
|
|
65
|
+
const PROTOCOL_PREFIX = '@@DSH:'
|
|
66
|
+
const MAX_CHATTER_LINES = 40
|
|
67
|
+
const DEFAULT_TIMEOUT_MS = 180000
|
|
68
|
+
|
|
69
|
+
/** Shared output contract: the tool returns one text block. */
|
|
70
|
+
function toolOutput() {
|
|
71
|
+
return {
|
|
72
|
+
schema: {
|
|
73
|
+
type: 'object',
|
|
74
|
+
additionalProperties: false,
|
|
75
|
+
properties: { text: { type: 'string' } },
|
|
76
|
+
required: ['text'],
|
|
77
|
+
},
|
|
78
|
+
render: (_args, value) => [{ type: 'text', text: value.text }],
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Trailing whitespace and NULs from MATLAB output are pure noise. */
|
|
83
|
+
function trimEnd(value) {
|
|
84
|
+
return String(value).replace(/[\s\0]+$/, '')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Render an `eval` reply as the model-facing text block. */
|
|
88
|
+
function formatEval(resp) {
|
|
89
|
+
const lines = []
|
|
90
|
+
if (resp.out) lines.push(trimEnd(resp.out))
|
|
91
|
+
if (resp.err) {
|
|
92
|
+
lines.push('MATLAB error: ' + resp.err)
|
|
93
|
+
if (resp.stack) lines.push('error stack:\n' + trimEnd(resp.stack))
|
|
94
|
+
}
|
|
95
|
+
if (Array.isArray(resp.chatter) && resp.chatter.length > 0) {
|
|
96
|
+
lines.push('matlab stderr:\n' + resp.chatter.join('\n'))
|
|
97
|
+
}
|
|
98
|
+
if (lines.length === 0) lines.push('(no output)')
|
|
99
|
+
return lines.join('\n')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Render a `debug` reply as the model-facing text block. */
|
|
103
|
+
function formatDebug(action, resp) {
|
|
104
|
+
if (resp.ok === false) return 'matlab_debug ' + action + ' failed: ' + (resp.err || 'unknown error')
|
|
105
|
+
const lines = []
|
|
106
|
+
if (resp.state !== undefined) lines.push('state: ' + resp.state)
|
|
107
|
+
if (resp.paused !== undefined) lines.push('paused: ' + resp.paused)
|
|
108
|
+
if (resp.running !== undefined) lines.push('running: ' + resp.running)
|
|
109
|
+
if (resp.out) lines.push(trimEnd(resp.out))
|
|
110
|
+
if (resp.stack) lines.push('stack:\n' + trimEnd(resp.stack))
|
|
111
|
+
if (resp.err) lines.push('error: ' + resp.err)
|
|
112
|
+
if (Array.isArray(resp.chatter) && resp.chatter.length > 0) {
|
|
113
|
+
lines.push('matlab stderr:\n' + resp.chatter.join('\n'))
|
|
114
|
+
}
|
|
115
|
+
if (lines.length === 0) lines.push('ok')
|
|
116
|
+
return lines.join('\n')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Render a `figure` reply as the model-facing text block. */
|
|
120
|
+
function formatFigure(action, resp) {
|
|
121
|
+
if (resp.ok === false) return 'matlab_figure ' + action + ' failed: ' + (resp.err || 'unknown error')
|
|
122
|
+
const figures = Array.isArray(resp.figures) ? resp.figures : []
|
|
123
|
+
|
|
124
|
+
if (action === 'list') {
|
|
125
|
+
if (figures.length === 0) return 'no figures are open'
|
|
126
|
+
return figures
|
|
127
|
+
.map((f) => 'figure ' + f.number + (f.name ? ' "' + f.name + '"' : '') + ' [' + f.visible + ']')
|
|
128
|
+
.join('\n')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (action === 'save') {
|
|
132
|
+
if (figures.length === 0) return 'no figures are open; nothing to export'
|
|
133
|
+
const lines = []
|
|
134
|
+
const exported = []
|
|
135
|
+
for (const f of figures) {
|
|
136
|
+
if (f.error) {
|
|
137
|
+
lines.push('figure ' + f.number + ': export FAILED -- ' + f.error)
|
|
138
|
+
} else {
|
|
139
|
+
lines.push('figure ' + f.number + ' -> ' + f.path)
|
|
140
|
+
exported.push(f.path)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Name the exported files explicitly: the caller must read them back with
|
|
144
|
+
// read_image, and a bare path list makes that step easy to skip.
|
|
145
|
+
if (exported.length > 0) {
|
|
146
|
+
lines.push('', 'Read each PNG with the read_image tool: ' + exported.join(', '))
|
|
147
|
+
}
|
|
148
|
+
return lines.join('\n')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (action === 'close') return 'figures closed'
|
|
152
|
+
return 'ok'
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Register the MATLAB tools and own the driver process for this mount. */
|
|
156
|
+
export function apply(ctx, config) {
|
|
157
|
+
const configuredPython = typeof config?.pythonPath === 'string' && config.pythonPath.length > 0
|
|
158
|
+
? config.pythonPath
|
|
159
|
+
: null
|
|
160
|
+
const configuredWorkDir = typeof config?.workDir === 'string' && config.workDir.length > 0
|
|
161
|
+
? config.workDir
|
|
162
|
+
: null
|
|
163
|
+
const configuredFigureDir = typeof config?.figureDir === 'string' && config.figureDir.length > 0
|
|
164
|
+
? config.figureDir
|
|
165
|
+
: null
|
|
166
|
+
const timeoutMs = Number.isSafeInteger(config?.timeoutMs) && config.timeoutMs > 0
|
|
167
|
+
? config.timeoutMs
|
|
168
|
+
: DEFAULT_TIMEOUT_MS
|
|
169
|
+
|
|
170
|
+
/** The calling session's cwd, learned from the first tool call. */
|
|
171
|
+
let sessionCwd = null
|
|
172
|
+
let resolvedPython = null
|
|
173
|
+
|
|
174
|
+
let handle = null
|
|
175
|
+
let driverStart = null
|
|
176
|
+
let nextId = 1
|
|
177
|
+
let buffer = ''
|
|
178
|
+
let chatter = []
|
|
179
|
+
let lastExit = null
|
|
180
|
+
const pending = new Map()
|
|
181
|
+
|
|
182
|
+
/** Prefer the session cwd so script-relative paths behave as the user expects. */
|
|
183
|
+
function workDir() {
|
|
184
|
+
return sessionCwd || configuredWorkDir || process.cwd()
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function figureDir() {
|
|
188
|
+
return configuredFigureDir || join(workDir(), '.matlab-figures')
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function rememberSession(exec) {
|
|
192
|
+
const cwd = exec?.agent?.session?.header?.cwd
|
|
193
|
+
if (typeof cwd === 'string' && cwd.length > 0) sessionCwd = cwd
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function pushChatter(line) {
|
|
197
|
+
if (line.trim() === '') return
|
|
198
|
+
chatter.push(line)
|
|
199
|
+
if (chatter.length > MAX_CHATTER_LINES) chatter.splice(0, chatter.length - MAX_CHATTER_LINES)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function feed(chunk) {
|
|
203
|
+
buffer += chunk
|
|
204
|
+
let index = buffer.indexOf('\n')
|
|
205
|
+
while (index >= 0) {
|
|
206
|
+
const line = buffer.slice(0, index).replace(/\r$/, '')
|
|
207
|
+
buffer = buffer.slice(index + 1)
|
|
208
|
+
index = buffer.indexOf('\n')
|
|
209
|
+
if (line.startsWith(PROTOCOL_PREFIX)) {
|
|
210
|
+
let message
|
|
211
|
+
try {
|
|
212
|
+
message = JSON.parse(line.slice(PROTOCOL_PREFIX.length))
|
|
213
|
+
} catch {
|
|
214
|
+
continue
|
|
215
|
+
}
|
|
216
|
+
const waiter = pending.get(message.id)
|
|
217
|
+
if (waiter !== undefined) {
|
|
218
|
+
pending.delete(message.id)
|
|
219
|
+
waiter.resolve(message)
|
|
220
|
+
}
|
|
221
|
+
} else {
|
|
222
|
+
pushChatter(line)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function failAll(reason) {
|
|
228
|
+
for (const waiter of pending.values()) waiter.reject(new Error(reason))
|
|
229
|
+
pending.clear()
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Discover a usable interpreter, once per mount. */
|
|
233
|
+
async function resolvePython() {
|
|
234
|
+
if (resolvedPython !== null) return resolvedPython
|
|
235
|
+
if (configuredPython !== null) {
|
|
236
|
+
try {
|
|
237
|
+
resolvedPython = await ctx.subprocess.resolveExecutable(configuredPython)
|
|
238
|
+
return resolvedPython
|
|
239
|
+
} catch (error) {
|
|
240
|
+
throw new Error('the configured pythonPath ' + JSON.stringify(configuredPython)
|
|
241
|
+
+ ' could not be resolved: ' + String((error && error.message) || error))
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const tried = []
|
|
245
|
+
for (const candidate of PYTHON_CANDIDATES) {
|
|
246
|
+
try {
|
|
247
|
+
resolvedPython = await ctx.subprocess.resolveExecutable(candidate)
|
|
248
|
+
return resolvedPython
|
|
249
|
+
} catch {
|
|
250
|
+
tried.push(candidate)
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
throw new Error('no Python interpreter found on PATH (tried ' + tried.join(', ') + '). '
|
|
254
|
+
+ 'Install Python 3.9-3.13, or set the `pythonPath` option on the matlab-bridge row.')
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function spawnDriver() {
|
|
258
|
+
const python = await resolvePython()
|
|
259
|
+
const spawned = ctx.subprocess.spawn({
|
|
260
|
+
argv: [python, '-u', DRIVER_PATH],
|
|
261
|
+
cwd: workDir(),
|
|
262
|
+
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'pipe' },
|
|
263
|
+
graceMs: 10000,
|
|
264
|
+
})
|
|
265
|
+
spawned.stdout.setEncoding('utf8')
|
|
266
|
+
spawned.stderr.setEncoding('utf8')
|
|
267
|
+
spawned.stdout.on('data', feed)
|
|
268
|
+
spawned.stderr.on('data', pushChatter)
|
|
269
|
+
spawned.done.then((outcome) => {
|
|
270
|
+
lastExit = 'bridge driver exited with code ' + outcome.exitCode
|
|
271
|
+
failAll(lastExit)
|
|
272
|
+
handle = null
|
|
273
|
+
}, (error) => {
|
|
274
|
+
lastExit = 'bridge driver failed: ' + error.message
|
|
275
|
+
failAll(lastExit)
|
|
276
|
+
handle = null
|
|
277
|
+
})
|
|
278
|
+
handle = spawned
|
|
279
|
+
buffer = ''
|
|
280
|
+
return spawned
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Start at most one driver even when several calls race for it. */
|
|
284
|
+
async function ensureDriver() {
|
|
285
|
+
if (handle !== null) return handle
|
|
286
|
+
if (driverStart === null) {
|
|
287
|
+
driverStart = spawnDriver().finally(() => { driverStart = null })
|
|
288
|
+
}
|
|
289
|
+
return driverStart
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Send one request and resolve with its id-matched reply. A timeout releases
|
|
294
|
+
* the caller without killing MATLAB -- a slow script must not cost the
|
|
295
|
+
* session -- and the late reply is simply dropped because its id is gone.
|
|
296
|
+
*/
|
|
297
|
+
async function rpc(op, params, limit) {
|
|
298
|
+
const active = await ensureDriver()
|
|
299
|
+
const id = nextId
|
|
300
|
+
nextId += 1
|
|
301
|
+
const budget = limit === undefined ? timeoutMs : limit
|
|
302
|
+
chatter = []
|
|
303
|
+
const resp = await new Promise((resolve, reject) => {
|
|
304
|
+
let timer = null
|
|
305
|
+
// The waiter clears its own timeout, so a reply that lands just as the
|
|
306
|
+
// limit fires cannot reject an already-resolved call.
|
|
307
|
+
pending.set(id, {
|
|
308
|
+
resolve: (value) => { if (timer !== null) clearTimeout(timer); resolve(value) },
|
|
309
|
+
reject: (error) => { if (timer !== null) clearTimeout(timer); reject(error) },
|
|
310
|
+
})
|
|
311
|
+
try {
|
|
312
|
+
active.stdin.write(JSON.stringify({ id, op, ...params }) + '\n')
|
|
313
|
+
} catch (error) {
|
|
314
|
+
const waiter = pending.get(id)
|
|
315
|
+
pending.delete(id)
|
|
316
|
+
if (waiter !== undefined) waiter.reject(new Error('could not write to the matlab bridge: ' + error.message))
|
|
317
|
+
return
|
|
318
|
+
}
|
|
319
|
+
timer = setTimeout(() => {
|
|
320
|
+
if (!pending.has(id)) return
|
|
321
|
+
pending.delete(id)
|
|
322
|
+
reject(new Error('matlab bridge timed out after ' + budget + 'ms' + (lastExit === null ? '' : ' (' + lastExit + ')')))
|
|
323
|
+
}, budget)
|
|
324
|
+
})
|
|
325
|
+
if (chatter.length > 0) resp.chatter = chatter.slice()
|
|
326
|
+
return resp
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** A `run` or `continue` may legitimately block on a slow MATLAB script. */
|
|
330
|
+
function debugTimeout(action, waitMs) {
|
|
331
|
+
if (action === 'run') return (waitMs || 8000) + timeoutMs
|
|
332
|
+
if (action === 'continue' || action === 'finish') return (waitMs || 15000) + timeoutMs
|
|
333
|
+
return timeoutMs
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
ctx.tools.register({
|
|
337
|
+
name: 'matlab_run',
|
|
338
|
+
description: [
|
|
339
|
+
'Run MATLAB code in a persistent MATLAB session and return its command-window output.',
|
|
340
|
+
'* Variables, figures and loaded data persist across calls, so later calls can build on earlier ones.',
|
|
341
|
+
'* Print with disp/fprintf, or pass a single bare expression to have its value shown.',
|
|
342
|
+
'* On failure the MATLAB error message and error stack are returned.',
|
|
343
|
+
'* The first call starts MATLAB and takes roughly 20 seconds; later calls are immediate.',
|
|
344
|
+
].join('\n'),
|
|
345
|
+
parameters: {
|
|
346
|
+
type: 'object',
|
|
347
|
+
properties: {
|
|
348
|
+
code: {
|
|
349
|
+
type: 'string',
|
|
350
|
+
description: 'MATLAB statements to execute, or a single expression whose value should be shown.',
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
required: ['code'],
|
|
354
|
+
additionalProperties: false,
|
|
355
|
+
},
|
|
356
|
+
output: toolOutput(),
|
|
357
|
+
async execute(args, exec) {
|
|
358
|
+
rememberSession(exec)
|
|
359
|
+
const resp = await rpc('eval', { code: String(args.code) })
|
|
360
|
+
return { text: formatEval(resp) }
|
|
361
|
+
},
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
ctx.tools.register({
|
|
365
|
+
name: 'matlab_debug',
|
|
366
|
+
description: [
|
|
367
|
+
'Drive the MATLAB debugger in the persistent session: set breakpoints, run until one hits, inspect the paused frame, and step line by line.',
|
|
368
|
+
'* Typical flow: action="break" (with file and line) -> action="run" (with code) -> action="vars"/"get"/"stack" -> action="step" -> action="continue".',
|
|
369
|
+
'* "eval" evaluates an expression inside the paused frame, so it can confirm a hypothesis about a live local.',
|
|
370
|
+
'* When run stops at a breakpoint the session stays paused; finish it with "continue" or abandon it with "quit".',
|
|
371
|
+
].join('\n'),
|
|
372
|
+
parameters: {
|
|
373
|
+
type: 'object',
|
|
374
|
+
properties: {
|
|
375
|
+
action: {
|
|
376
|
+
type: 'string',
|
|
377
|
+
description: 'One of: break, breakError, clearBreaks, run, status, stack, vars, get, eval, step, stepIn, stepOut, continue, quit, finish.',
|
|
378
|
+
},
|
|
379
|
+
file: { type: 'string', description: 'For break: function or script name holding the breakpoint, e.g. myfunc.' },
|
|
380
|
+
line: { type: 'number', description: 'For break: line number. Omit to stop at the function entry. A breakpoint on a comment or blank line binds to the next executable line.' },
|
|
381
|
+
name: { type: 'string', description: 'For get: variable name in the paused frame.' },
|
|
382
|
+
code: { type: 'string', description: 'For run: the MATLAB code to launch. For eval: the expression to evaluate in the paused frame.' },
|
|
383
|
+
waitMs: { type: 'number', description: 'For run/continue/finish: how long to wait for a pause or completion before returning. Defaults to 8000 for run and 15000 otherwise.' },
|
|
384
|
+
},
|
|
385
|
+
required: ['action'],
|
|
386
|
+
additionalProperties: false,
|
|
387
|
+
},
|
|
388
|
+
output: toolOutput(),
|
|
389
|
+
async execute(args, exec) {
|
|
390
|
+
rememberSession(exec)
|
|
391
|
+
const action = String(args.action)
|
|
392
|
+
const params = { action }
|
|
393
|
+
if (args.file !== undefined) params.file = String(args.file)
|
|
394
|
+
if (args.line !== undefined) params.line = Number(args.line)
|
|
395
|
+
if (args.name !== undefined) params.name = String(args.name)
|
|
396
|
+
if (args.code !== undefined) params.code = String(args.code)
|
|
397
|
+
if (args.waitMs !== undefined) params.waitMs = Number(args.waitMs)
|
|
398
|
+
const resp = await rpc('debug', params, debugTimeout(action, params.waitMs))
|
|
399
|
+
return { text: formatDebug(action, resp) }
|
|
400
|
+
},
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
ctx.tools.register({
|
|
404
|
+
name: 'matlab_figure',
|
|
405
|
+
description: [
|
|
406
|
+
'Inspect and export MATLAB figures from the persistent session.',
|
|
407
|
+
'* Plot first with matlab_run; figures stay open in the session, so export afterwards.',
|
|
408
|
+
'* action="save" writes each open figure to a PNG and returns the paths. Read those PNGs with the read_image tool -- the exported file is how the plot actually becomes visible.',
|
|
409
|
+
'* action="list" reports which figures are open; action="close" closes one or all of them.',
|
|
410
|
+
].join('\n'),
|
|
411
|
+
parameters: {
|
|
412
|
+
type: 'object',
|
|
413
|
+
properties: {
|
|
414
|
+
action: { type: 'string', description: 'One of: list, save, close.' },
|
|
415
|
+
figure: { type: 'number', description: 'A figure number for save or close. Omit, or pass 0, to cover every open figure.' },
|
|
416
|
+
dir: { type: 'string', description: 'Optional output directory for save. Defaults to a .matlab-figures directory under the session working directory.' },
|
|
417
|
+
},
|
|
418
|
+
required: ['action'],
|
|
419
|
+
additionalProperties: false,
|
|
420
|
+
},
|
|
421
|
+
output: toolOutput(),
|
|
422
|
+
async execute(args, exec) {
|
|
423
|
+
rememberSession(exec)
|
|
424
|
+
const action = String(args.action)
|
|
425
|
+
const params = { action }
|
|
426
|
+
if (args.figure !== undefined) params.figure = Number(args.figure)
|
|
427
|
+
if (args.dir !== undefined) params.dir = String(args.dir)
|
|
428
|
+
else if (action === 'save') params.dir = figureDir()
|
|
429
|
+
const resp = await rpc('figure', params, timeoutMs)
|
|
430
|
+
return { text: formatFigure(action, resp) }
|
|
431
|
+
},
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
ctx.tools.register({
|
|
435
|
+
name: 'matlab_session',
|
|
436
|
+
description: [
|
|
437
|
+
'Manage the persistent MATLAB session shared by matlab_run, matlab_debug and matlab_figure.',
|
|
438
|
+
'* MATLAB takes tens of seconds to start, so the session stays warm and is reused across calls.',
|
|
439
|
+
'* action="status" reports whether it is up; action="stop" shuts MATLAB down and releases it.',
|
|
440
|
+
'* action="start" also reports the actionable error when the engine runtime has not been laid out yet.',
|
|
441
|
+
].join('\n'),
|
|
442
|
+
parameters: {
|
|
443
|
+
type: 'object',
|
|
444
|
+
properties: {
|
|
445
|
+
action: { type: 'string', description: 'One of: start, status, stop.' },
|
|
446
|
+
},
|
|
447
|
+
required: ['action'],
|
|
448
|
+
additionalProperties: false,
|
|
449
|
+
},
|
|
450
|
+
output: toolOutput(),
|
|
451
|
+
async execute(args, exec) {
|
|
452
|
+
rememberSession(exec)
|
|
453
|
+
const action = String(args.action)
|
|
454
|
+
if (action === 'stop') {
|
|
455
|
+
if (handle === null) return { text: 'no MATLAB session is running' }
|
|
456
|
+
try {
|
|
457
|
+
await rpc('shutdown', {}, 60000)
|
|
458
|
+
} catch {
|
|
459
|
+
// The driver exits as part of shutdown, so a dropped reply is expected.
|
|
460
|
+
}
|
|
461
|
+
try {
|
|
462
|
+
handle.terminate()
|
|
463
|
+
} catch {
|
|
464
|
+
// Already gone.
|
|
465
|
+
}
|
|
466
|
+
handle = null
|
|
467
|
+
pending.clear()
|
|
468
|
+
return { text: 'MATLAB session stopped' }
|
|
469
|
+
}
|
|
470
|
+
if (action === 'status') {
|
|
471
|
+
const runtimePresent = existsSync(join(PYLIBS_PATH, 'matlab'))
|
|
472
|
+
const lines = [
|
|
473
|
+
'package: ' + PACKAGE_ROOT,
|
|
474
|
+
'driver: ' + (handle === null ? 'not running' : 'running'),
|
|
475
|
+
// Name the exact command rather than a bare bin name: this package is
|
|
476
|
+
// normally installed into a DSH profile, where its bin is not on
|
|
477
|
+
// PATH, so "run dsh-matlab-bridge-setup" is not actionable.
|
|
478
|
+
'engine runtime: ' + (runtimePresent
|
|
479
|
+
? 'present'
|
|
480
|
+
: 'MISSING - run: node ' + JSON.stringify(join(PACKAGE_ROOT, 'scripts', 'setup-engine.mjs'))),
|
|
481
|
+
]
|
|
482
|
+
if (handle === null) {
|
|
483
|
+
lines.push('matlab: not started')
|
|
484
|
+
return { text: lines.join('\n') }
|
|
485
|
+
}
|
|
486
|
+
const resp = await rpc('ping', {}, 30000)
|
|
487
|
+
lines.push('matlab: ' + (resp.engineRunning ? 'running' : 'not started'))
|
|
488
|
+
return { text: lines.join('\n') }
|
|
489
|
+
}
|
|
490
|
+
if (action === 'start') {
|
|
491
|
+
const resp = await rpc('start', {}, timeoutMs)
|
|
492
|
+
if (resp.ok === false) return { text: 'MATLAB failed to start: ' + resp.err }
|
|
493
|
+
return { text: 'MATLAB ' + (resp.version || '') + ' started' }
|
|
494
|
+
}
|
|
495
|
+
return { text: 'unknown action: ' + action + ' (use start, status or stop)' }
|
|
496
|
+
},
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
// The driver owns a MATLAB process; the mount must not leak it.
|
|
500
|
+
ctx.effect(() => () => {
|
|
501
|
+
if (handle !== null) {
|
|
502
|
+
try {
|
|
503
|
+
handle.terminate()
|
|
504
|
+
} catch {
|
|
505
|
+
// Nothing left to release.
|
|
506
|
+
}
|
|
507
|
+
handle = null
|
|
508
|
+
}
|
|
509
|
+
failAll('matlab-bridge plugin was stopped')
|
|
510
|
+
})
|
|
511
|
+
}
|