scanruler-mcp 0.4.0 → 0.4.5

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/relay.js CHANGED
@@ -24,6 +24,9 @@ export class PeerLink extends Asker {
24
24
  this.token = token
25
25
  this.version = version
26
26
  this.log = log
27
+ /** The holding copy's version, as its welcome said; null from a copy
28
+ * before 0.4.1, whose welcome to another copy carried none. */
29
+ this.holderVersion = null
27
30
  }
28
31
 
29
32
  /** Connect to the copy on the port. Resolves `{ ok: true }` once it took
@@ -40,6 +43,7 @@ export class PeerLink extends Asker {
40
43
  if (message.welcome) {
41
44
  accepted = true
42
45
  this.socket = ws
46
+ this.holderVersion = typeof message.welcome.version === 'string' ? message.welcome.version : null
43
47
  this.#setPage(message.welcome.page ?? null)
44
48
  resolve({ ok: true })
45
49
  } else resolve({ refused: String(message.refused ?? 'It said no.') })
@@ -141,6 +145,12 @@ export class SharedLink extends EventEmitter {
141
145
  return this.current?.page ?? null
142
146
  }
143
147
 
148
+ /** While relaying: the version of the copy holding the port, null when
149
+ * it is from before 0.4.1 and did not say. Undefined otherwise. */
150
+ get holderVersion() {
151
+ return this.role === 'relay' ? (this.peer?.holderVersion ?? null) : undefined
152
+ }
153
+
144
154
  run(name, input, binaryKey, onProgress, job, deferred = false) {
145
155
  const link = this.current
146
156
  if (!link) return Promise.reject({ code: 'unavailable', message: 'No ScanRuler page is connected.' })
package/src/tools.js CHANGED
@@ -20,31 +20,38 @@ export function bytesProperty(command) {
20
20
  return Object.keys(props).find((key) => props[key]?.type === 'string' && props[key]?.format === 'binary') ?? null
21
21
  }
22
22
 
23
- const inPath = {
23
+ /** Where a relative path is taken from, as every path field says it: the
24
+ * folder a scan or a project was last opened from or saved into — as
25
+ * scanruler_write and scanruler_files take it — else the folder the
26
+ * server started in (mcp.js, pathBase). */
27
+ export const relativeFrom = (cwd) =>
28
+ `A relative path is taken from the folder of the scan or project last opened or saved (scanruler_status: server.relativeTo) — before any, from ${cwd ?? 'the folder this server started in'}.`
29
+
30
+ const inPath = (cwd) => ({
24
31
  type: 'string',
25
32
  minLength: 1,
26
- description: 'The file to read, on this computer: an absolute path, or one relative to the folder the agent works in.',
27
- }
33
+ description: `The file to read, on this computer: an absolute path, or a relative one. ${relativeFrom(cwd)}`,
34
+ })
28
35
 
29
- const outPath = (outDir) => ({
36
+ const outPath = (outDir, cwd) => ({
30
37
  type: 'string',
31
38
  minLength: 1,
32
- description: `Where to write the file: a file path, or a folder to write it into under the name ScanRuler gives it. Without it, it goes into ${outDir}.`,
39
+ description: `Where to write the file: a file path — one there already is replaced — or a folder (one there, or a path ending in /) to write it into under the name ScanRuler gives it, numbered past a file of that name there (-2, -3 …). Without it, it goes into ${outDir} the same way. ${relativeFrom(cwd)}`,
33
40
  })
34
41
 
35
- const imagePath = (outDir) => ({
42
+ const imagePath = (outDir, cwd) => ({
36
43
  type: 'string',
37
44
  minLength: 1,
38
- description: `Also write the picture to a file: a file path, or a folder to write it into under the name ScanRuler gives it. Without it, it is only shown — but in scanruler_batch, which shows no picture, it is written into ${outDir} under a new name.`,
45
+ description: `Also write the picture to a file: a file path — one there already is replaced — or a folder (one there, or a path ending in /) to write it into under the name ScanRuler gives it, numbered past a picture of that name there (-2, -3 …). Without it, it is only shown — but in scanruler_batch, which shows no picture, it is written under a new name into the folder of the scan or project last opened or saved (the latest of scanruler_status folders), before any into ${outDir}. ${relativeFrom(cwd)}`,
39
46
  })
40
47
 
41
48
  /** The `saveTo` a command that replaces the session takes — one with
42
49
  * `discard`: the session saved as a project file first (mcp.js). */
43
- const saveToIn = {
50
+ const saveToIn = (cwd) => ({
44
51
  type: 'string',
45
52
  minLength: 1,
46
- description: 'Save the session as a .scanruler project file here first — a file path, or a folder to save it into under its own name — then go on as with discard: true. If the save fails nothing is replaced. The answer says where it went (savedTo).',
47
- }
53
+ description: `Save the session as a .scanruler project file here first — a file path, or a folder to save it into under its own name — then go on as with discard: true. If the save fails nothing is replaced. The answer says where it went (savedTo). ${relativeFrom(cwd)}`,
54
+ })
48
55
 
49
56
  /** Whether a command replaces the session, refusing to over work unless
50
57
  * told `discard` — what `saveTo` is offered on. */
@@ -93,7 +100,7 @@ export const timingsIn = {
93
100
  description: 'true: the answer adds ms (the call, start to answer) and, where the change built, steps.played — each step the build played with its ms (steps.restored counts those taken up from a kept state, not played).',
94
101
  }
95
102
 
96
- export function toolFor(command, { outDir }) {
103
+ export function toolFor(command, { outDir, cwd }) {
97
104
  const input = structuredClone(command.input ?? { type: 'object' })
98
105
  input.properties = { ...(input.properties ?? {}) }
99
106
  let required = [...(input.required ?? [])]
@@ -102,7 +109,8 @@ export function toolFor(command, { outDir }) {
102
109
  if (bytesKey) {
103
110
  delete input.properties[bytesKey]
104
111
  const pick = FOLDER_PICKS[command.name]
105
- input.properties.path = pick ? { ...inPath, description: `${inPath.description} A folder opens the one ${pick.what} file in it (${pick.kinds.join(', ')}); with more than one, the answer lists them.` } : inPath
112
+ const read = inPath(cwd)
113
+ input.properties.path = pick ? { ...read, description: `${read.description} A folder opens the one ${pick.what} file in it (${pick.kinds.join(', ')}); with more than one, the answer lists them.` } : read
106
114
  required = required.filter((key) => key !== bytesKey && key !== 'name')
107
115
  if (!required.includes('path')) required.unshift('path')
108
116
  if (input.properties.name) {
@@ -111,15 +119,15 @@ export function toolFor(command, { outDir }) {
111
119
  description += ' The file is read from `path` on this computer and streamed to the page.'
112
120
  }
113
121
  if (command.returnsImage) {
114
- input.properties.path = imagePath(outDir)
122
+ input.properties.path = imagePath(outDir, cwd)
115
123
  input.properties.inline = inlineIn
116
124
  description += ' The picture comes back as an image; with `path` it is written to a file too, and with `inline: false` only there.'
117
125
  } else if (command.returnsFile) {
118
- input.properties.path = outPath(outDir)
126
+ input.properties.path = outPath(outDir, cwd)
119
127
  description += ' The file is written to `path`; the result says where and how large it is, not its bytes.'
120
128
  }
121
129
  if (replacesSession(command)) {
122
- input.properties.saveTo = saveToIn
130
+ input.properties.saveTo = saveToIn(cwd)
123
131
  description += ' With `saveTo`, the session is saved as a project file there first (as project_save does), then replaced as with discard: true — one call to keep what a page holds and start anew.'
124
132
  }
125
133
  if (!command.readOnly) {
@@ -259,7 +267,8 @@ export async function inputFor(command, args, { cwd }) {
259
267
  /** Arguments with every `{ "$file": path }` in them — an object of that one
260
268
  * key, at any depth — replaced by the JSON the file holds: a long list of
261
269
  * stations a script wrote need not pass through the conversation. A
262
- * relative path is taken from `cwd`. What a file holds is not looked
270
+ * relative path is taken from `cwd` — the folder the server takes
271
+ * relative paths from (mcp.js, pathBase). What a file holds is not looked
263
272
  * into again. */
264
273
  export async function withFiles(value, cwd) {
265
274
  if (Array.isArray(value)) return Promise.all(value.map((v) => withFiles(v, cwd)))
@@ -308,32 +317,63 @@ export async function imageContent(result, args, { cwd, outDir }) {
308
317
  ]
309
318
  }
310
319
 
311
- /** A picture a batched call handed back, which a batch cannot show:
312
- * written where the call asked, or into `outDir` under the name ScanRuler
313
- * gave it — numbered past one already there, so a batch of renders, or a
314
- * render from an earlier batch, is not written over. The result says
315
- * where, in place of the bytes. A result with no picture in it is as
316
- * saveFileResult leaves it. */
317
- export async function batchedPicture(result, args, { cwd, outDir }) {
318
- const file = result?.file
319
- if (args?.path || !file || !(file.bytes instanceof Uint8Array)) return saveFileResult(result, args, { cwd, outDir })
320
- const name = String(file.name ?? 'picture.png')
320
+ /** How many numbered names writeNew tries before it gives up. */
321
+ const NUMBERED_MOST = 10_000
322
+
323
+ /** `data` written to a new file in `folder` under `name` — or, a file of
324
+ * that name being there, as `stem-2.ext`, `stem-3.ext` …: each is made
325
+ * with `wx`, which refuses one that is there, so two written at once —
326
+ * pictures rendered side by side, answers spilled together — never take
327
+ * the same name, and nothing earlier is written over. Returns its path. */
328
+ export async function writeNew(folder, name, data) {
329
+ await mkdir(folder, { recursive: true })
321
330
  const dot = name.lastIndexOf('.')
322
331
  const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, '']
323
- let target = join(outDir, name)
324
- for (let n = 2; await stat(target).catch(() => null); n++) target = join(outDir, `${stem}-${n}${ext}`)
325
- return saveFileResult(result, { path: target }, { cwd, outDir })
332
+ for (let n = 1; n <= NUMBERED_MOST; n++) {
333
+ const target = join(folder, n === 1 ? name : `${stem}-${n}${ext}`)
334
+ try {
335
+ await writeFile(target, data, { flag: 'wx' })
336
+ return target
337
+ } catch (e) {
338
+ if (e?.code !== 'EEXIST') throw e
339
+ }
340
+ }
341
+ throw { code: 'failed', message: `${folder} already holds ${NUMBERED_MOST} files named like ${name}: give a path to write it to.` }
342
+ }
343
+
344
+ /** A picture a batched call handed back, which a batch cannot show:
345
+ * written where the call asked, or into `folder` — the latest a scan or
346
+ * a project came from, which a sandboxed agent can open — else, or when
347
+ * that folder takes no file, into `outDir`; as saveFileResult writes any
348
+ * file, numbered past one already there, so a batch of renders, or a
349
+ * render from an earlier batch, is not written over. The result says
350
+ * where, in place of the bytes. */
351
+ export async function batchedPicture(result, args, { cwd, outDir, folder }) {
352
+ if (args?.path || !folder) return saveFileResult(result, args, { cwd, outDir })
353
+ try {
354
+ return await saveFileResult(result, args, { cwd, outDir: folder })
355
+ } catch {
356
+ return saveFileResult(result, args, { cwd, outDir })
357
+ }
326
358
  }
327
359
 
328
360
  /** Write a returned file where the call asked, or into `outDir`; the result
329
- * then says where, in place of the bytes. */
361
+ * then says where, in place of the bytes. A file path given is written,
362
+ * over one there; a folder — one there, or a path ending in a separator —
363
+ * or `outDir` takes the name ScanRuler gave the file, numbered past one
364
+ * already there (writeNew). */
330
365
  export async function saveFileResult(result, args, { cwd, outDir }) {
331
366
  const file = result?.file
332
367
  if (!file || !(file.bytes instanceof Uint8Array)) return result
333
- let target = args?.path ? absolute(String(args.path), cwd) : join(outDir, file.name)
334
- const existing = await stat(target).catch(() => null)
335
- if (existing?.isDirectory()) target = join(target, file.name)
336
- await mkdir(dirname(target), { recursive: true })
337
- await writeFile(target, file.bytes)
368
+ const name = basename(String(file.name ?? 'file'))
369
+ const given = args?.path ? absolute(String(args.path), cwd) : null
370
+ const folder = given === null ? outDir : /[\\/]$/.test(String(args.path)) || (await stat(given).catch(() => null))?.isDirectory() ? given : null
371
+ let target
372
+ if (folder !== null) target = await writeNew(folder, name, file.bytes)
373
+ else {
374
+ target = given
375
+ await mkdir(dirname(target), { recursive: true })
376
+ await writeFile(target, file.bytes)
377
+ }
338
378
  return { ...result, file: { name: file.name, mimeType: file.mimeType, size: file.bytes.byteLength, path: target } }
339
379
  }