@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/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
+ }