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/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 at once with { queued: true, job } while the call runs on; scanruler_wait { for: "job", job } gives its answer. true (the default): answer once it is done — or, still running at the server’s answer limit (scanruler_status: server.answerWithin, seconds), with { queued: true, job, after } all the same.',
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 (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).',
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
- export const fileKind = (name) => {
301
- const ext = extname(name).toLowerCase()
302
- return Object.keys(KINDS).find((kind) => KINDS[kind].includes(ext)) ?? 'other'
303
- }
304
-
305
- /** A picture a command handed back, as MCP content: the image, and the rest
306
- * of the result as text — where the picture was written, if the call gave
307
- * a path, in place of its bytes; written and not shown with `inline:
308
- * false`. Null for a result with no picture in it. */
309
- export async function imageContent(result, args, { cwd, outDir }) {
310
- const file = result?.file
311
- if (!file || !(file.bytes instanceof Uint8Array) || !String(file.mimeType ?? '').startsWith('image/')) return null
312
- const saved = args?.path ? await saveFileResult(result, args, { cwd, outDir }) : { ...result, file: { name: file.name, mimeType: file.mimeType, size: file.bytes.byteLength } }
313
- if (args?.path && args?.inline === false) return [{ type: 'text', text: agentJson(saved) }]
314
- return [
315
- { type: 'text', text: agentJson(saved) },
316
- { type: 'image', data: Buffer.from(file.bytes.buffer, file.bytes.byteOffset, file.bytes.byteLength).toString('base64'), mimeType: file.mimeType },
317
- ]
318
- }
319
-
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 })
330
- const dot = name.lastIndexOf('.')
331
- const [stem, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, '']
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
- }
358
- }
359
-
360
- /** Write a returned file where the call asked, or into `outDir`; the result
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). */
365
- export async function saveFileResult(result, args, { cwd, outDir }) {
366
- const file = result?.file
367
- if (!file || !(file.bytes instanceof Uint8Array)) return result
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
- }
378
- return { ...result, file: { name: file.name, mimeType: file.mimeType, size: file.bytes.byteLength, path: target } }
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
+ }