scanruler-mcp 0.4.5 → 0.6.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/README.md +43 -17
- package/package.json +41 -41
- package/src/commands.json +381 -310
- package/src/main.js +14 -8
- package/src/mcp.js +272 -47
- package/src/tools.js +398 -379
- package/src/trace.js +12 -1
package/src/tools.js
CHANGED
|
@@ -1,379 +1,398 @@
|
|
|
1
|
-
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
-
// The page's commands as MCP tools. A command's name becomes the tool's,
|
|
3
|
-
// dots to underscores — element.fit is element_fit — and its JSON Schema
|
|
4
|
-
// passes through, but for files: a command that takes a file's bytes takes a
|
|
5
|
-
// path on this computer instead, and this server reads the file and streams
|
|
6
|
-
// it to the page; a command that hands a file back takes an optional path,
|
|
7
|
-
// and this server writes the file there. The agent never sees the bytes —
|
|
8
|
-
// but for a picture (a command marked returnsImage), which it is shown as an
|
|
9
|
-
// image, and which is written to a file only when it gives a path — or, in
|
|
10
|
-
// a batch, where it cannot be shown, always.
|
|
11
|
-
|
|
12
|
-
import { readdir, readFile, stat, writeFile, mkdir } from 'node:fs/promises'
|
|
13
|
-
import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path'
|
|
14
|
-
|
|
15
|
-
export const toolName = (command) => command.name.replace(/\./g, '_')
|
|
16
|
-
|
|
17
|
-
/** The input property that carries a file's bytes, if the command has one. */
|
|
18
|
-
export function bytesProperty(command) {
|
|
19
|
-
const props = command.input?.properties ?? {}
|
|
20
|
-
return Object.keys(props).find((key) => props[key]?.type === 'string' && props[key]?.format === 'binary') ?? null
|
|
21
|
-
}
|
|
22
|
-
|
|
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) => ({
|
|
31
|
-
type: 'string',
|
|
32
|
-
minLength: 1,
|
|
33
|
-
description: `The file to read, on this computer: an absolute path, or a relative one. ${relativeFrom(cwd)}`,
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
const outPath = (outDir, cwd) => ({
|
|
37
|
-
type: 'string',
|
|
38
|
-
minLength: 1,
|
|
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)}`,
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
const imagePath = (outDir, cwd) => ({
|
|
43
|
-
type: 'string',
|
|
44
|
-
minLength: 1,
|
|
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)}`,
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
/** The `saveTo` a command that replaces the session takes — one with
|
|
49
|
-
* `discard`: the session saved as a project file first (mcp.js). */
|
|
50
|
-
const saveToIn = (cwd) => ({
|
|
51
|
-
type: 'string',
|
|
52
|
-
minLength: 1,
|
|
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
|
-
})
|
|
55
|
-
|
|
56
|
-
/** Whether a command replaces the session, refusing to over work unless
|
|
57
|
-
* told `discard` — what `saveTo` is offered on. */
|
|
58
|
-
export const replacesSession = (command) => command.input?.properties?.discard?.type === 'boolean'
|
|
59
|
-
|
|
60
|
-
const inlineIn = {
|
|
61
|
-
type: 'boolean',
|
|
62
|
-
description: 'false, with path: the picture is written to the file and not shown — the answer says where. A picture costs far more context than its text; save the ones you only deliver.',
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** How many significant digits a number keeps in an answer, and the finest
|
|
66
|
-
* step it is given to: six digits is a micrometre on a part under a metre
|
|
67
|
-
* and a millionth of a unit vector; past them the page's doubles are
|
|
68
|
-
* noise the agent pays for — 0.9483828176573491 in place of 0.948383. */
|
|
69
|
-
const DIGITS = 6
|
|
70
|
-
const STEP = 1e6
|
|
71
|
-
|
|
72
|
-
const tidy = (v) => {
|
|
73
|
-
const r = Number(v.toPrecision(DIGITS))
|
|
74
|
-
return Math.abs(r) < 9e9 ? Math.round(r * STEP) / STEP || 0 : r
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** An answer as the agent reads it: JSON, every number that is not whole
|
|
78
|
-
* kept to DIGITS significant digits and no finer than a millionth — 1e-17,
|
|
79
|
-
* a zero's rounding error, is 0. Whole numbers — ids, indices, counts —
|
|
80
|
-
* are as they were. An `exact` answer is one whose numbers are handed
|
|
81
|
-
* back as inputs — a plane's frame, say — and keeps every digit:
|
|
82
|
-
* shortened, what is built on them is a hair off what it was meant to
|
|
83
|
-
* meet. */
|
|
84
|
-
export const agentJson = (value, exact = false) =>
|
|
85
|
-
exact ? JSON.stringify(value) : JSON.stringify(value, (_key, v) => (typeof v === 'number' && Number.isFinite(v) && !Number.isInteger(v) ? tidy(v) : v))
|
|
86
|
-
|
|
87
|
-
/** A command as the MCP tool an agent sees. */
|
|
88
|
-
/** The `wait` a tool that changes the session takes, and a long read
|
|
89
|
-
* (Command.long): the server runs every such call as a job (mcp.js), and
|
|
90
|
-
* false answers with the job at once. */
|
|
91
|
-
export const waitIn = {
|
|
92
|
-
type: 'boolean',
|
|
93
|
-
description: 'false: answer
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** The `timings` a tool that changes the session takes: the server keeps
|
|
97
|
-
* what the page's answer carries of the build's steps (mcp.js). */
|
|
98
|
-
export const timingsIn = {
|
|
99
|
-
type: 'boolean',
|
|
100
|
-
description: 'true: the answer adds ms
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export function toolFor(command, { outDir, cwd }) {
|
|
104
|
-
const input = structuredClone(command.input ?? { type: 'object' })
|
|
105
|
-
input.properties = { ...(input.properties ?? {}) }
|
|
106
|
-
let required = [...(input.required ?? [])]
|
|
107
|
-
const bytesKey = bytesProperty(command)
|
|
108
|
-
let description = command.description
|
|
109
|
-
if (bytesKey) {
|
|
110
|
-
delete input.properties[bytesKey]
|
|
111
|
-
const pick = FOLDER_PICKS[command.name]
|
|
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
|
|
114
|
-
required = required.filter((key) => key !== bytesKey && key !== 'name')
|
|
115
|
-
if (!required.includes('path')) required.unshift('path')
|
|
116
|
-
if (input.properties.name) {
|
|
117
|
-
input.properties.name = { ...input.properties.name, description: `${input.properties.name.description ?? ''} Defaults to the file's own name.`.trim() }
|
|
118
|
-
}
|
|
119
|
-
description += ' The file is read from `path` on this computer and streamed to the page.'
|
|
120
|
-
}
|
|
121
|
-
if (command.returnsImage) {
|
|
122
|
-
input.properties.path = imagePath(outDir, cwd)
|
|
123
|
-
input.properties.inline = inlineIn
|
|
124
|
-
description += ' The picture comes back as an image; with `path` it is written to a file too, and with `inline: false` only there.'
|
|
125
|
-
} else if (command.returnsFile) {
|
|
126
|
-
input.properties.path = outPath(outDir, cwd)
|
|
127
|
-
description += ' The file is written to `path`; the result says where and how large it is, not its bytes.'
|
|
128
|
-
}
|
|
129
|
-
if (replacesSession(command)) {
|
|
130
|
-
input.properties.saveTo = saveToIn(cwd)
|
|
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.'
|
|
132
|
-
}
|
|
133
|
-
if (!command.readOnly) {
|
|
134
|
-
input.properties.wait = waitIn
|
|
135
|
-
input.properties.timings = timingsIn
|
|
136
|
-
} else if (command.long) {
|
|
137
|
-
// A long read runs as a job too (mcp.js), so it takes wait.
|
|
138
|
-
input.properties.wait = waitIn
|
|
139
|
-
}
|
|
140
|
-
if (required.length) input.required = required
|
|
141
|
-
else delete input.required
|
|
142
|
-
return {
|
|
143
|
-
name: toolName(command),
|
|
144
|
-
title: command.title,
|
|
145
|
-
description,
|
|
146
|
-
inputSchema: input,
|
|
147
|
-
annotations: { title: command.title, readOnlyHint: Boolean(command.readOnly) && (!command.returnsFile || Boolean(command.returnsImage)), openWorldHint: false },
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** A path as the agent gave it, made absolute against `base`. */
|
|
152
|
-
export const absolute = (path, base) => (isAbsolute(path) ? path : resolve(base, path))
|
|
153
|
-
|
|
154
|
-
/** What a folder given in place of a file opens, by command: the one file
|
|
155
|
-
* of its kind in it. Other commands take a file only. */
|
|
156
|
-
const FOLDER_PICKS = {
|
|
157
|
-
'scan.open': { what: 'mesh', kinds: ['.stl', '.ply', '.obj'] },
|
|
158
|
-
'project.load': { what: 'project', kinds: ['.scanruler'] },
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/** The file a path names: itself, or — for a folder, where the command
|
|
162
|
-
* takes one (FOLDER_PICKS) — the only file of the command's kind in it.
|
|
163
|
-
* Refused with the folder's files listed otherwise. */
|
|
164
|
-
async function fileAt(command, file) {
|
|
165
|
-
const found = await stat(file).catch(() => null)
|
|
166
|
-
if (!found?.isDirectory()) return file
|
|
167
|
-
const names = (await readdir(file, { withFileTypes: true })).filter((d) => d.isFile()).map((d) => d.name)
|
|
168
|
-
const listed = (list) => (list.length > 20 ? `${list.slice(0, 20).join(', ')} and ${list.length - 20} more` : list.join(', '))
|
|
169
|
-
const pick = FOLDER_PICKS[command.name]
|
|
170
|
-
if (!pick) throw { code: 'invalid_input', message: `path: ${file} is a folder — give a file${names.length ? ` in it: ${listed(names)}` : ''}.` }
|
|
171
|
-
const matches = names.filter((n) => pick.kinds.includes(extname(n).toLowerCase()))
|
|
172
|
-
if (matches.length === 1) return join(file, matches[0])
|
|
173
|
-
if (matches.length === 0) {
|
|
174
|
-
throw { code: 'invalid_input', message: `path: ${file} is a folder with no ${pick.what} file in it (${pick.kinds.join(', ')})${names.length ? ` — it holds ${listed(names)}` : ' — it is empty'}. Give the file’s path.` }
|
|
175
|
-
}
|
|
176
|
-
throw { code: 'invalid_input', message: `path: ${file} is a folder with ${matches.length} ${pick.what} files — give one of them by its path: ${listed(matches)}.` }
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/** How many near names a missing path's refusal lists at most, and how many
|
|
180
|
-
* letters off a near name may be. */
|
|
181
|
-
const NEAR_LISTED = 5
|
|
182
|
-
const NEAR_LETTERS = 2
|
|
183
|
-
|
|
184
|
-
/** A name as nearNames compares it: case ignored, and "-", "_" and a
|
|
185
|
-
* space taken alike. */
|
|
186
|
-
const nearForm = (name) => name.toLowerCase().replace(/[-_ ]/g, '_')
|
|
187
|
-
|
|
188
|
-
/** How many letters `a` and `b` are apart — added, taken out or changed —
|
|
189
|
-
* or `most + 1` once past `most`. */
|
|
190
|
-
function lettersApart(a, b, most) {
|
|
191
|
-
if (Math.abs(a.length - b.length) > most) return most + 1
|
|
192
|
-
let row = Array.from({ length: b.length + 1 }, (_, j) => j)
|
|
193
|
-
for (let i = 1; i <= a.length; i++) {
|
|
194
|
-
const next = [i]
|
|
195
|
-
for (let j = 1; j <= b.length; j++) next[j] = Math.min(row[j] + 1, next[j - 1] + 1, row[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
|
|
196
|
-
if (Math.min(...next) > most) return most + 1
|
|
197
|
-
row = next
|
|
198
|
-
}
|
|
199
|
-
return row[b.length]
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/** Where a path that is not there may have been meant: the first folder up
|
|
203
|
-
* from it that is there, the name in the path missing from it, and up to
|
|
204
|
-
* NEAR_LISTED names in that folder at most NEAR_LETTERS letters off it — a typed
|
|
205
|
-
* path's slip, "10_scooter_head" for "10_scooterHead". A near folder is
|
|
206
|
-
* given with the rest of the path when that is in it. */
|
|
207
|
-
export async function nearNames(path) {
|
|
208
|
-
let missing = resolve(path)
|
|
209
|
-
let folder = dirname(missing)
|
|
210
|
-
let rest = []
|
|
211
|
-
for (;;) {
|
|
212
|
-
if ((await stat(folder).catch(() => null))?.isDirectory()) break
|
|
213
|
-
if (dirname(folder) === folder) return { folder: null, missing: null, near: [] }
|
|
214
|
-
rest = [basename(missing), ...rest]
|
|
215
|
-
missing = folder
|
|
216
|
-
folder = dirname(folder)
|
|
217
|
-
}
|
|
218
|
-
const want = nearForm(basename(missing))
|
|
219
|
-
const names = await readdir(folder).catch(() => [])
|
|
220
|
-
const near = names
|
|
221
|
-
.map((name) => ({ name, apart: lettersApart(nearForm(name), want, NEAR_LETTERS) }))
|
|
222
|
-
.filter((n) => n.apart <= NEAR_LETTERS)
|
|
223
|
-
.sort((a, b) => a.apart - b.apart || a.name.localeCompare(b.name))
|
|
224
|
-
.slice(0, NEAR_LISTED)
|
|
225
|
-
const paths = []
|
|
226
|
-
for (const { name } of near) {
|
|
227
|
-
const whole = join(folder, name, ...rest)
|
|
228
|
-
paths.push(rest.length && (await stat(whole).catch(() => null)) ? whole : join(folder, name))
|
|
229
|
-
}
|
|
230
|
-
return { folder, missing: basename(missing), near: paths }
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/** nearNames as a refusal's last sentence. */
|
|
234
|
-
async function nearNote(path) {
|
|
235
|
-
const { folder, missing, near } = await nearNames(path)
|
|
236
|
-
if (!folder) return ''
|
|
237
|
-
if (near.length) return ` Near it: ${near.join(', ')}.`
|
|
238
|
-
return ` ${folder} is there, with nothing in it named like ${missing}.`
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/** The tool call's arguments as the command's input: a path read into the
|
|
242
|
-
* bytes it names. Returns the input, the input field the bytes go in, and
|
|
243
|
-
* the file read — a folder's one file, when the path named a folder. */
|
|
244
|
-
export async function inputFor(command, args, { cwd }) {
|
|
245
|
-
const input = { ...(args ?? {}) }
|
|
246
|
-
delete input.saveTo
|
|
247
|
-
const bytesKey = bytesProperty(command)
|
|
248
|
-
if (bytesKey) {
|
|
249
|
-
const file = await fileAt(command, absolute(String(input.path ?? ''), cwd))
|
|
250
|
-
delete input.path
|
|
251
|
-
let bytes
|
|
252
|
-
try {
|
|
253
|
-
bytes = new Uint8Array(await readFile(file))
|
|
254
|
-
} catch (e) {
|
|
255
|
-
if (e.code !== 'ENOENT') throw { code: 'invalid_input', message: `Could not read ${file}: ${e.message}.` }
|
|
256
|
-
throw { code: 'invalid_input', message: `Could not read ${file}: there is no such file.${await nearNote(file)}` }
|
|
257
|
-
}
|
|
258
|
-
input[bytesKey] = bytes
|
|
259
|
-
if (command.input?.properties?.name && input.name === undefined) input.name = basename(file)
|
|
260
|
-
return { input, binaryKey: bytesKey, file }
|
|
261
|
-
}
|
|
262
|
-
if (command.returnsFile || command.returnsImage) delete input.path
|
|
263
|
-
if (command.returnsImage) delete input.inline
|
|
264
|
-
return { input, binaryKey: null, file: null }
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/** Arguments with every `{ "$file": path }` in them — an object of that one
|
|
268
|
-
* key, at any depth — replaced by the JSON the file holds: a long list of
|
|
269
|
-
* stations a script wrote need not pass through the conversation. A
|
|
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
|
|
272
|
-
* into again. */
|
|
273
|
-
export async function withFiles(value, cwd) {
|
|
274
|
-
if (Array.isArray(value)) return Promise.all(value.map((v) => withFiles(v, cwd)))
|
|
275
|
-
if (!value || typeof value !== 'object' || value instanceof Uint8Array) return value
|
|
276
|
-
const keys = Object.keys(value)
|
|
277
|
-
if (keys.length === 1 && keys[0] === '$file') {
|
|
278
|
-
if (typeof value.$file !== 'string' || !value.$file) throw { code: 'invalid_input', message: '$file: give the path of a JSON file.' }
|
|
279
|
-
const path = absolute(value.$file, cwd)
|
|
280
|
-
try {
|
|
281
|
-
return JSON.parse(await readFile(path, 'utf8'))
|
|
282
|
-
} catch (e) {
|
|
283
|
-
throw { code: 'invalid_input', message: `$file: ${path} could not be read as JSON (${e?.code === 'ENOENT' ? 'there is no such file' : e instanceof Error ? e.message : String(e)}).` }
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
const out = {}
|
|
287
|
-
for (const key of keys) out[key] = await withFiles(value[key], cwd)
|
|
288
|
-
return out
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/** A file's kind by its extension, as scanruler_files lists it. */
|
|
292
|
-
const KINDS = {
|
|
293
|
-
mesh: ['.stl', '.ply', '.obj', '.3mf', '.off'],
|
|
294
|
-
project: ['.scanruler'],
|
|
295
|
-
step: ['.step', '.stp'],
|
|
296
|
-
picture: ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp'],
|
|
297
|
-
drawing: ['.dxf', '.svg', '.pdf'],
|
|
298
|
-
text: ['.md', '.txt', '.csv', '.json', '.jsonl'],
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
await
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// The page's commands as MCP tools. A command's name becomes the tool's,
|
|
3
|
+
// dots to underscores — element.fit is element_fit — and its JSON Schema
|
|
4
|
+
// passes through, but for files: a command that takes a file's bytes takes a
|
|
5
|
+
// path on this computer instead, and this server reads the file and streams
|
|
6
|
+
// it to the page; a command that hands a file back takes an optional path,
|
|
7
|
+
// and this server writes the file there. The agent never sees the bytes —
|
|
8
|
+
// but for a picture (a command marked returnsImage), which it is shown as an
|
|
9
|
+
// image, and which is written to a file only when it gives a path — or, in
|
|
10
|
+
// a batch, where it cannot be shown, always.
|
|
11
|
+
|
|
12
|
+
import { readdir, readFile, stat, writeFile, mkdir } from 'node:fs/promises'
|
|
13
|
+
import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path'
|
|
14
|
+
|
|
15
|
+
export const toolName = (command) => command.name.replace(/\./g, '_')
|
|
16
|
+
|
|
17
|
+
/** The input property that carries a file's bytes, if the command has one. */
|
|
18
|
+
export function bytesProperty(command) {
|
|
19
|
+
const props = command.input?.properties ?? {}
|
|
20
|
+
return Object.keys(props).find((key) => props[key]?.type === 'string' && props[key]?.format === 'binary') ?? null
|
|
21
|
+
}
|
|
22
|
+
|
|
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) => ({
|
|
31
|
+
type: 'string',
|
|
32
|
+
minLength: 1,
|
|
33
|
+
description: `The file to read, on this computer: an absolute path, or a relative one. ${relativeFrom(cwd)}`,
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const outPath = (outDir, cwd) => ({
|
|
37
|
+
type: 'string',
|
|
38
|
+
minLength: 1,
|
|
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)}`,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const imagePath = (outDir, cwd) => ({
|
|
43
|
+
type: 'string',
|
|
44
|
+
minLength: 1,
|
|
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)}`,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
/** The `saveTo` a command that replaces the session takes — one with
|
|
49
|
+
* `discard`: the session saved as a project file first (mcp.js). */
|
|
50
|
+
const saveToIn = (cwd) => ({
|
|
51
|
+
type: 'string',
|
|
52
|
+
minLength: 1,
|
|
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
|
+
})
|
|
55
|
+
|
|
56
|
+
/** Whether a command replaces the session, refusing to over work unless
|
|
57
|
+
* told `discard` — what `saveTo` is offered on. */
|
|
58
|
+
export const replacesSession = (command) => command.input?.properties?.discard?.type === 'boolean'
|
|
59
|
+
|
|
60
|
+
const inlineIn = {
|
|
61
|
+
type: 'boolean',
|
|
62
|
+
description: 'false, with path: the picture is written to the file and not shown — the answer says where. A picture costs far more context than its text; save the ones you only deliver.',
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** How many significant digits a number keeps in an answer, and the finest
|
|
66
|
+
* step it is given to: six digits is a micrometre on a part under a metre
|
|
67
|
+
* and a millionth of a unit vector; past them the page's doubles are
|
|
68
|
+
* noise the agent pays for — 0.9483828176573491 in place of 0.948383. */
|
|
69
|
+
const DIGITS = 6
|
|
70
|
+
const STEP = 1e6
|
|
71
|
+
|
|
72
|
+
const tidy = (v) => {
|
|
73
|
+
const r = Number(v.toPrecision(DIGITS))
|
|
74
|
+
return Math.abs(r) < 9e9 ? Math.round(r * STEP) / STEP || 0 : r
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** An answer as the agent reads it: JSON, every number that is not whole
|
|
78
|
+
* kept to DIGITS significant digits and no finer than a millionth — 1e-17,
|
|
79
|
+
* a zero's rounding error, is 0. Whole numbers — ids, indices, counts —
|
|
80
|
+
* are as they were. An `exact` answer is one whose numbers are handed
|
|
81
|
+
* back as inputs — a plane's frame, say — and keeps every digit:
|
|
82
|
+
* shortened, what is built on them is a hair off what it was meant to
|
|
83
|
+
* meet. */
|
|
84
|
+
export const agentJson = (value, exact = false) =>
|
|
85
|
+
exact ? JSON.stringify(value) : JSON.stringify(value, (_key, v) => (typeof v === 'number' && Number.isFinite(v) && !Number.isInteger(v) ? tidy(v) : v))
|
|
86
|
+
|
|
87
|
+
/** A command as the MCP tool an agent sees. */
|
|
88
|
+
/** The `wait` a tool that changes the session takes, and a long read
|
|
89
|
+
* (Command.long): the server runs every such call as a job (mcp.js), and
|
|
90
|
+
* false answers with the job at once. */
|
|
91
|
+
export const waitIn = {
|
|
92
|
+
type: 'boolean',
|
|
93
|
+
description: 'false: answer { queued: true, job } at once and run on; scanruler_wait { for: "job", job } gives the answer. true, the default: answer when done — or queued all the same past the server’s answer limit.',
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The `timings` a tool that changes the session takes: the server keeps
|
|
97
|
+
* what the page's answer carries of the build's steps (mcp.js). */
|
|
98
|
+
export const timingsIn = {
|
|
99
|
+
type: 'boolean',
|
|
100
|
+
description: 'true: the answer adds ms and, where the change built, steps.played — each step with its ms.',
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function toolFor(command, { outDir, cwd }) {
|
|
104
|
+
const input = structuredClone(command.input ?? { type: 'object' })
|
|
105
|
+
input.properties = { ...(input.properties ?? {}) }
|
|
106
|
+
let required = [...(input.required ?? [])]
|
|
107
|
+
const bytesKey = bytesProperty(command)
|
|
108
|
+
let description = command.description
|
|
109
|
+
if (bytesKey) {
|
|
110
|
+
delete input.properties[bytesKey]
|
|
111
|
+
const pick = FOLDER_PICKS[command.name]
|
|
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
|
|
114
|
+
required = required.filter((key) => key !== bytesKey && key !== 'name')
|
|
115
|
+
if (!required.includes('path')) required.unshift('path')
|
|
116
|
+
if (input.properties.name) {
|
|
117
|
+
input.properties.name = { ...input.properties.name, description: `${input.properties.name.description ?? ''} Defaults to the file's own name.`.trim() }
|
|
118
|
+
}
|
|
119
|
+
description += ' The file is read from `path` on this computer and streamed to the page.'
|
|
120
|
+
}
|
|
121
|
+
if (command.returnsImage) {
|
|
122
|
+
input.properties.path = imagePath(outDir, cwd)
|
|
123
|
+
input.properties.inline = inlineIn
|
|
124
|
+
description += ' The picture comes back as an image; with `path` it is written to a file too, and with `inline: false` only there.'
|
|
125
|
+
} else if (command.returnsFile) {
|
|
126
|
+
input.properties.path = outPath(outDir, cwd)
|
|
127
|
+
description += ' The file is written to `path`; the result says where and how large it is, not its bytes.'
|
|
128
|
+
}
|
|
129
|
+
if (replacesSession(command)) {
|
|
130
|
+
input.properties.saveTo = saveToIn(cwd)
|
|
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.'
|
|
132
|
+
}
|
|
133
|
+
if (!command.readOnly) {
|
|
134
|
+
input.properties.wait = waitIn
|
|
135
|
+
input.properties.timings = timingsIn
|
|
136
|
+
} else if (command.long) {
|
|
137
|
+
// A long read runs as a job too (mcp.js), so it takes wait.
|
|
138
|
+
input.properties.wait = waitIn
|
|
139
|
+
}
|
|
140
|
+
if (required.length) input.required = required
|
|
141
|
+
else delete input.required
|
|
142
|
+
return {
|
|
143
|
+
name: toolName(command),
|
|
144
|
+
title: command.title,
|
|
145
|
+
description,
|
|
146
|
+
inputSchema: input,
|
|
147
|
+
annotations: { title: command.title, readOnlyHint: Boolean(command.readOnly) && (!command.returnsFile || Boolean(command.returnsImage)), openWorldHint: false },
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A path as the agent gave it, made absolute against `base`. */
|
|
152
|
+
export const absolute = (path, base) => (isAbsolute(path) ? path : resolve(base, path))
|
|
153
|
+
|
|
154
|
+
/** What a folder given in place of a file opens, by command: the one file
|
|
155
|
+
* of its kind in it. Other commands take a file only. */
|
|
156
|
+
const FOLDER_PICKS = {
|
|
157
|
+
'scan.open': { what: 'mesh', kinds: ['.stl', '.ply', '.obj', '.3mf'] },
|
|
158
|
+
'project.load': { what: 'project', kinds: ['.scanruler'] },
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The file a path names: itself, or — for a folder, where the command
|
|
162
|
+
* takes one (FOLDER_PICKS) — the only file of the command's kind in it.
|
|
163
|
+
* Refused with the folder's files listed otherwise. */
|
|
164
|
+
async function fileAt(command, file) {
|
|
165
|
+
const found = await stat(file).catch(() => null)
|
|
166
|
+
if (!found?.isDirectory()) return file
|
|
167
|
+
const names = (await readdir(file, { withFileTypes: true })).filter((d) => d.isFile()).map((d) => d.name)
|
|
168
|
+
const listed = (list) => (list.length > 20 ? `${list.slice(0, 20).join(', ')} and ${list.length - 20} more` : list.join(', '))
|
|
169
|
+
const pick = FOLDER_PICKS[command.name]
|
|
170
|
+
if (!pick) throw { code: 'invalid_input', message: `path: ${file} is a folder — give a file${names.length ? ` in it: ${listed(names)}` : ''}.` }
|
|
171
|
+
const matches = names.filter((n) => pick.kinds.includes(extname(n).toLowerCase()))
|
|
172
|
+
if (matches.length === 1) return join(file, matches[0])
|
|
173
|
+
if (matches.length === 0) {
|
|
174
|
+
throw { code: 'invalid_input', message: `path: ${file} is a folder with no ${pick.what} file in it (${pick.kinds.join(', ')})${names.length ? ` — it holds ${listed(names)}` : ' — it is empty'}. Give the file’s path.` }
|
|
175
|
+
}
|
|
176
|
+
throw { code: 'invalid_input', message: `path: ${file} is a folder with ${matches.length} ${pick.what} files — give one of them by its path: ${listed(matches)}.` }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** How many near names a missing path's refusal lists at most, and how many
|
|
180
|
+
* letters off a near name may be. */
|
|
181
|
+
const NEAR_LISTED = 5
|
|
182
|
+
const NEAR_LETTERS = 2
|
|
183
|
+
|
|
184
|
+
/** A name as nearNames compares it: case ignored, and "-", "_" and a
|
|
185
|
+
* space taken alike. */
|
|
186
|
+
const nearForm = (name) => name.toLowerCase().replace(/[-_ ]/g, '_')
|
|
187
|
+
|
|
188
|
+
/** How many letters `a` and `b` are apart — added, taken out or changed —
|
|
189
|
+
* or `most + 1` once past `most`. */
|
|
190
|
+
function lettersApart(a, b, most) {
|
|
191
|
+
if (Math.abs(a.length - b.length) > most) return most + 1
|
|
192
|
+
let row = Array.from({ length: b.length + 1 }, (_, j) => j)
|
|
193
|
+
for (let i = 1; i <= a.length; i++) {
|
|
194
|
+
const next = [i]
|
|
195
|
+
for (let j = 1; j <= b.length; j++) next[j] = Math.min(row[j] + 1, next[j - 1] + 1, row[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
|
|
196
|
+
if (Math.min(...next) > most) return most + 1
|
|
197
|
+
row = next
|
|
198
|
+
}
|
|
199
|
+
return row[b.length]
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Where a path that is not there may have been meant: the first folder up
|
|
203
|
+
* from it that is there, the name in the path missing from it, and up to
|
|
204
|
+
* NEAR_LISTED names in that folder at most NEAR_LETTERS letters off it — a typed
|
|
205
|
+
* path's slip, "10_scooter_head" for "10_scooterHead". A near folder is
|
|
206
|
+
* given with the rest of the path when that is in it. */
|
|
207
|
+
export async function nearNames(path) {
|
|
208
|
+
let missing = resolve(path)
|
|
209
|
+
let folder = dirname(missing)
|
|
210
|
+
let rest = []
|
|
211
|
+
for (;;) {
|
|
212
|
+
if ((await stat(folder).catch(() => null))?.isDirectory()) break
|
|
213
|
+
if (dirname(folder) === folder) return { folder: null, missing: null, near: [] }
|
|
214
|
+
rest = [basename(missing), ...rest]
|
|
215
|
+
missing = folder
|
|
216
|
+
folder = dirname(folder)
|
|
217
|
+
}
|
|
218
|
+
const want = nearForm(basename(missing))
|
|
219
|
+
const names = await readdir(folder).catch(() => [])
|
|
220
|
+
const near = names
|
|
221
|
+
.map((name) => ({ name, apart: lettersApart(nearForm(name), want, NEAR_LETTERS) }))
|
|
222
|
+
.filter((n) => n.apart <= NEAR_LETTERS)
|
|
223
|
+
.sort((a, b) => a.apart - b.apart || a.name.localeCompare(b.name))
|
|
224
|
+
.slice(0, NEAR_LISTED)
|
|
225
|
+
const paths = []
|
|
226
|
+
for (const { name } of near) {
|
|
227
|
+
const whole = join(folder, name, ...rest)
|
|
228
|
+
paths.push(rest.length && (await stat(whole).catch(() => null)) ? whole : join(folder, name))
|
|
229
|
+
}
|
|
230
|
+
return { folder, missing: basename(missing), near: paths }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** nearNames as a refusal's last sentence. */
|
|
234
|
+
async function nearNote(path) {
|
|
235
|
+
const { folder, missing, near } = await nearNames(path)
|
|
236
|
+
if (!folder) return ''
|
|
237
|
+
if (near.length) return ` Near it: ${near.join(', ')}.`
|
|
238
|
+
return ` ${folder} is there, with nothing in it named like ${missing}.`
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The tool call's arguments as the command's input: a path read into the
|
|
242
|
+
* bytes it names. Returns the input, the input field the bytes go in, and
|
|
243
|
+
* the file read — a folder's one file, when the path named a folder. */
|
|
244
|
+
export async function inputFor(command, args, { cwd }) {
|
|
245
|
+
const input = { ...(args ?? {}) }
|
|
246
|
+
delete input.saveTo
|
|
247
|
+
const bytesKey = bytesProperty(command)
|
|
248
|
+
if (bytesKey) {
|
|
249
|
+
const file = await fileAt(command, absolute(String(input.path ?? ''), cwd))
|
|
250
|
+
delete input.path
|
|
251
|
+
let bytes
|
|
252
|
+
try {
|
|
253
|
+
bytes = new Uint8Array(await readFile(file))
|
|
254
|
+
} catch (e) {
|
|
255
|
+
if (e.code !== 'ENOENT') throw { code: 'invalid_input', message: `Could not read ${file}: ${e.message}.` }
|
|
256
|
+
throw { code: 'invalid_input', message: `Could not read ${file}: there is no such file.${await nearNote(file)}` }
|
|
257
|
+
}
|
|
258
|
+
input[bytesKey] = bytes
|
|
259
|
+
if (command.input?.properties?.name && input.name === undefined) input.name = basename(file)
|
|
260
|
+
return { input, binaryKey: bytesKey, file }
|
|
261
|
+
}
|
|
262
|
+
if (command.returnsFile || command.returnsImage) delete input.path
|
|
263
|
+
if (command.returnsImage) delete input.inline
|
|
264
|
+
return { input, binaryKey: null, file: null }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Arguments with every `{ "$file": path }` in them — an object of that one
|
|
268
|
+
* key, at any depth — replaced by the JSON the file holds: a long list of
|
|
269
|
+
* stations a script wrote need not pass through the conversation. A
|
|
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
|
|
272
|
+
* into again. */
|
|
273
|
+
export async function withFiles(value, cwd) {
|
|
274
|
+
if (Array.isArray(value)) return Promise.all(value.map((v) => withFiles(v, cwd)))
|
|
275
|
+
if (!value || typeof value !== 'object' || value instanceof Uint8Array) return value
|
|
276
|
+
const keys = Object.keys(value)
|
|
277
|
+
if (keys.length === 1 && keys[0] === '$file') {
|
|
278
|
+
if (typeof value.$file !== 'string' || !value.$file) throw { code: 'invalid_input', message: '$file: give the path of a JSON file.' }
|
|
279
|
+
const path = absolute(value.$file, cwd)
|
|
280
|
+
try {
|
|
281
|
+
return JSON.parse(await readFile(path, 'utf8'))
|
|
282
|
+
} catch (e) {
|
|
283
|
+
throw { code: 'invalid_input', message: `$file: ${path} could not be read as JSON (${e?.code === 'ENOENT' ? 'there is no such file' : e instanceof Error ? e.message : String(e)}).` }
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
const out = {}
|
|
287
|
+
for (const key of keys) out[key] = await withFiles(value[key], cwd)
|
|
288
|
+
return out
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** A file's kind by its extension, as scanruler_files lists it. */
|
|
292
|
+
const KINDS = {
|
|
293
|
+
mesh: ['.stl', '.ply', '.obj', '.3mf', '.off'],
|
|
294
|
+
project: ['.scanruler'],
|
|
295
|
+
step: ['.step', '.stp'],
|
|
296
|
+
picture: ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp'],
|
|
297
|
+
drawing: ['.dxf', '.svg', '.pdf'],
|
|
298
|
+
text: ['.md', '.txt', '.csv', '.json', '.jsonl'],
|
|
299
|
+
}
|
|
300
|
+
/** The extensions of the files scanruler_read reads. */
|
|
301
|
+
export const TEXT_FILES = KINDS.text
|
|
302
|
+
export const fileKind = (name) => {
|
|
303
|
+
const ext = extname(name).toLowerCase()
|
|
304
|
+
return Object.keys(KINDS).find((kind) => KINDS[kind].includes(ext)) ?? 'other'
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** How many bytes the UTF-8 character that starts with `byte` takes. */
|
|
308
|
+
const utf8Length = (byte) => (byte < 0x80 ? 1 : byte >= 0xf0 ? 4 : byte >= 0xe0 ? 3 : 2)
|
|
309
|
+
|
|
310
|
+
/** The start of UTF-8 `bytes` up to `most` of them, ending on a character's
|
|
311
|
+
* end — the first character at least, when they hold it whole. */
|
|
312
|
+
export function wholeUtf8(bytes, most = bytes.length) {
|
|
313
|
+
const cut = bytes.subarray(0, Math.max(most, utf8Length(bytes[0])))
|
|
314
|
+
for (let back = 1; back <= Math.min(4, cut.length); back++) {
|
|
315
|
+
const byte = cut[cut.length - back]
|
|
316
|
+
if ((byte & 0xc0) === 0x80) continue
|
|
317
|
+
return utf8Length(byte) > back ? cut.subarray(0, cut.length - back) : cut
|
|
318
|
+
}
|
|
319
|
+
return cut
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** A picture a command handed back, as MCP content: the image, and the rest
|
|
323
|
+
* of the result as text — where the picture was written, if the call gave
|
|
324
|
+
* a path, in place of its bytes; written and not shown with `inline:
|
|
325
|
+
* false`. Null for a result with no picture in it. */
|
|
326
|
+
export async function imageContent(result, args, { cwd, outDir }) {
|
|
327
|
+
const file = result?.file
|
|
328
|
+
if (!file || !(file.bytes instanceof Uint8Array) || !String(file.mimeType ?? '').startsWith('image/')) return null
|
|
329
|
+
const saved = args?.path ? await saveFileResult(result, args, { cwd, outDir }) : { ...result, file: { name: file.name, mimeType: file.mimeType, size: file.bytes.byteLength } }
|
|
330
|
+
if (args?.path && args?.inline === false) return [{ type: 'text', text: agentJson(saved) }]
|
|
331
|
+
return [
|
|
332
|
+
{ type: 'text', text: agentJson(saved) },
|
|
333
|
+
{ type: 'image', data: Buffer.from(file.bytes.buffer, file.bytes.byteOffset, file.bytes.byteLength).toString('base64'), mimeType: file.mimeType },
|
|
334
|
+
]
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** How many numbered names writeNew tries before it gives up. */
|
|
338
|
+
const NUMBERED_MOST = 10_000
|
|
339
|
+
|
|
340
|
+
/** `data` written to a new file in `folder` under `name` — or, a file of
|
|
341
|
+
* that name being there, as `stem-2.ext`, `stem-3.ext` …: each is made
|
|
342
|
+
* with `wx`, which refuses one that is there, so two written at once —
|
|
343
|
+
* pictures rendered side by side, answers spilled together — never take
|
|
344
|
+
* the same name, and nothing earlier is written over. Returns its path. */
|
|
345
|
+
export async function writeNew(folder, name, data) {
|
|
346
|
+
await mkdir(folder, { recursive: true })
|
|
347
|
+
const dot = name.lastIndexOf('.')
|
|
348
|
+
const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, '']
|
|
349
|
+
for (let n = 1; n <= NUMBERED_MOST; n++) {
|
|
350
|
+
const target = join(folder, n === 1 ? name : `${stem}-${n}${ext}`)
|
|
351
|
+
try {
|
|
352
|
+
await writeFile(target, data, { flag: 'wx' })
|
|
353
|
+
return target
|
|
354
|
+
} catch (e) {
|
|
355
|
+
if (e?.code !== 'EEXIST') throw e
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
throw { code: 'failed', message: `${folder} already holds ${NUMBERED_MOST} files named like ${name}: give a path to write it to.` }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** A picture a batched call handed back, which a batch cannot show:
|
|
362
|
+
* written where the call asked, or into `folder` — the server's work
|
|
363
|
+
* folder in the one the scan came from, which a sandboxed agent can open,
|
|
364
|
+
* made when it is not there — else, or when that folder takes no file,
|
|
365
|
+
* into `outDir`; as saveFileResult writes any file, numbered past one
|
|
366
|
+
* already there, so a batch of renders, or a render from an earlier
|
|
367
|
+
* batch, is not written over. The result says where, in place of the
|
|
368
|
+
* bytes — written into `folder`, that folder too (file.folder). */
|
|
369
|
+
export async function batchedPicture(result, args, { cwd, outDir, folder }) {
|
|
370
|
+
if (args?.path || !folder) return saveFileResult(result, args, { cwd, outDir })
|
|
371
|
+
try {
|
|
372
|
+
const saved = await saveFileResult(result, args, { cwd, outDir: folder })
|
|
373
|
+
return saved?.file?.path ? { ...saved, file: { ...saved.file, folder } } : saved
|
|
374
|
+
} catch {
|
|
375
|
+
return saveFileResult(result, args, { cwd, outDir })
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Write a returned file where the call asked, or into `outDir`; the result
|
|
380
|
+
* then says where, in place of the bytes. A file path given is written,
|
|
381
|
+
* over one there; a folder — one there, or a path ending in a separator —
|
|
382
|
+
* or `outDir` takes the name ScanRuler gave the file, numbered past one
|
|
383
|
+
* already there (writeNew). */
|
|
384
|
+
export async function saveFileResult(result, args, { cwd, outDir }) {
|
|
385
|
+
const file = result?.file
|
|
386
|
+
if (!file || !(file.bytes instanceof Uint8Array)) return result
|
|
387
|
+
const name = basename(String(file.name ?? 'file'))
|
|
388
|
+
const given = args?.path ? absolute(String(args.path), cwd) : null
|
|
389
|
+
const folder = given === null ? outDir : /[\\/]$/.test(String(args.path)) || (await stat(given).catch(() => null))?.isDirectory() ? given : null
|
|
390
|
+
let target
|
|
391
|
+
if (folder !== null) target = await writeNew(folder, name, file.bytes)
|
|
392
|
+
else {
|
|
393
|
+
target = given
|
|
394
|
+
await mkdir(dirname(target), { recursive: true })
|
|
395
|
+
await writeFile(target, file.bytes)
|
|
396
|
+
}
|
|
397
|
+
return { ...result, file: { name: file.name, mimeType: file.mimeType, size: file.bytes.byteLength, path: target } }
|
|
398
|
+
}
|