agentic-numerical-control 0.1.8 → 0.2.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.
@@ -1,21 +1,18 @@
1
- // Link to CNCjs over its socket.io API, with a cached view of controller
2
- // state. Agentic Numerical Control uses an existing CNCjs at CNCJS_URL when one answers there;
3
- // otherwise it starts the CNCjs bundled in node_modules on that address,
4
- // with its own config file in .anc/.
5
- import { spawn } from "node:child_process";
1
+ // Link to ANC's own headless CNCjs (engines/cncjs, built into
2
+ // dist/engines/cncjs) over its socket.io API, with a cached view of controller
3
+ // state. ANC always starts and owns this engine as a child process on a free
4
+ // loopback port; it never looks for, adopts or controls any other CNCjs.
5
+ import { fork } from "node:child_process";
6
6
  import fs from "node:fs";
7
- import os from "node:os";
8
7
  import path from "node:path";
9
- import { createRequire } from "node:module";
10
8
  import jwt from "jsonwebtoken";
11
9
  import io from "socket.io-client";
12
- const require = createRequire(import.meta.url);
13
- export const CNCJS_URL = process.env.CNCJS_URL ?? "http://127.0.0.1:8000";
14
10
  import { STATE_DIR } from "./paths.js";
15
11
  import { ROOT } from "./product.js";
16
12
  import { programHash } from "./program.js";
13
+ /** ANC's CNCjs config (CNCjs's .cncrc format). Not the user's ~/.cncrc. */
17
14
  const MANAGED_CONFIG = path.join(STATE_DIR, "cncrc");
18
- const USER_CONFIG = process.env.CNCJS_CONFIG ?? path.join(os.homedir(), ".cncrc");
15
+ const ENGINE_DIR = path.join(ROOT, "dist", "engines", "cncjs");
19
16
  /** Telemetry older than this is shown as stale rather than live. */
20
17
  const STALE_MS = 3000;
21
18
  /** Wait this long after a failed connect before trying again. */
@@ -24,7 +21,10 @@ const MOVING_STATES = new Set(["Run", "Jog", "Home"]);
24
21
  class CNCjsLink {
25
22
  socket = null;
26
23
  child = null;
27
- managed = false;
24
+ url = null;
25
+ version = null;
26
+ /** The engine's last "Error opening serial port" log line, per port. */
27
+ openErrors = new Map();
28
28
  connected = false;
29
29
  error = null;
30
30
  starting = null;
@@ -97,7 +97,7 @@ class CNCjsLink {
97
97
  for (const fn of this.onRunEndListeners)
98
98
  fn(run);
99
99
  }
100
- /** Connects (starting a bundled CNCjs if needed). Safe to call repeatedly. */
100
+ /** Starts the engine if needed and connects to it. Safe to call repeatedly. */
101
101
  ensure() {
102
102
  if (this.connected)
103
103
  return Promise.resolve();
@@ -111,16 +111,9 @@ class CNCjsLink {
111
111
  }
112
112
  async start() {
113
113
  try {
114
- // An already-running CNCjs is either the user's (~/.cncrc) or one Agentic Numerical Control
115
- // started earlier and outlived (.anc/cncrc).
116
- let configFile = this.managed || (!fs.existsSync(USER_CONFIG) && fs.existsSync(MANAGED_CONFIG))
117
- ? MANAGED_CONFIG
118
- : USER_CONFIG;
119
- if (!(await reachable(CNCJS_URL))) {
120
- await this.launchBundled();
121
- configFile = MANAGED_CONFIG;
122
- }
123
- await this.connect(accessToken(configFile));
114
+ if (!this.child)
115
+ await this.launch();
116
+ await this.connect(accessToken());
124
117
  this.error = null;
125
118
  }
126
119
  catch (e) {
@@ -129,47 +122,80 @@ class CNCjsLink {
129
122
  throw e;
130
123
  }
131
124
  }
132
- async launchBundled() {
133
- const url = new URL(CNCJS_URL);
134
- if (!["127.0.0.1", "localhost"].includes(url.hostname)) {
135
- throw new Error(`No CNCjs answered at ${CNCJS_URL}, and Agentic Numerical Control only starts its own CNCjs on this computer.`);
125
+ launch() {
126
+ const manifestPath = path.join(ENGINE_DIR, "manifest.json");
127
+ if (!fs.existsSync(manifestPath)) {
128
+ return Promise.reject(new Error(`The built-in CNCjs is missing (${ENGINE_DIR}). Run npm run build.`));
136
129
  }
130
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
137
131
  fs.mkdirSync(STATE_DIR, { recursive: true });
138
- const bin = require.resolve("cncjs/bin/cncjs");
139
- const port = url.port || "8000";
140
- console.error(`[anc] Starting bundled CNCjs on 127.0.0.1:${port} (config ${MANAGED_CONFIG})`);
141
132
  // The preload adds the GRBL-SIM port (sim/) alongside real serial ports.
142
133
  const simHook = path.join(ROOT, "sim", "virtual-port.cjs");
143
- this.child = spawn(process.execPath, ["--require", simHook, bin, "-H", "127.0.0.1", "-p", port, "-c", MANAGED_CONFIG], {
144
- stdio: ["ignore", "ignore", "pipe"],
134
+ const child = fork(path.join(ENGINE_DIR, manifest.entry), [], {
135
+ execArgv: ["--require", simHook],
136
+ env: { ...process.env, ANC_CNCJS_CONFIG: MANAGED_CONFIG, ANC_CNCJS_PORT: process.env.ANC_CNCJS_PORT ?? "0" },
137
+ stdio: ["ignore", "pipe", "pipe", "ipc"],
145
138
  windowsHide: true,
146
139
  });
147
- this.child.stderr?.on("data", (d) => process.stderr.write(`[cncjs] ${d}`));
148
- this.child.on("exit", (code) => {
149
- console.error(`[anc] Bundled CNCjs exited (${code})`);
150
- this.child = null;
140
+ this.child = child;
141
+ // CNCjs logs to stdout; pass on its warnings and errors, and keep the
142
+ // reason a serial port failed to open (its error object doesn't survive
143
+ // socket.io).
144
+ const onLog = (d) => {
145
+ for (const line of d.toString().split(/\r?\n/)) {
146
+ const plain = line.replace(/\x1b\[[0-9;]*m/g, "");
147
+ const m = /Error opening serial port "([^"]+)":\s*(.*)/.exec(plain);
148
+ if (m)
149
+ this.openErrors.set(m[1], m[2].replace(/^\[?(Error:\s*)+/, "").replace(/\]$/, "").trim());
150
+ if (/ - (error|warn) /.test(plain) || (plain && !/ - \w+ /.test(plain)))
151
+ process.stderr.write(`[cncjs] ${plain}\n`);
152
+ }
153
+ };
154
+ child.stdout?.on("data", onLog);
155
+ child.stderr?.on("data", onLog);
156
+ child.on("exit", (code) => {
157
+ console.error(`[anc] CNCjs engine exited (${code})`);
158
+ if (this.child === child) {
159
+ this.child = null;
160
+ this.url = null;
161
+ this.connected = false;
162
+ this.socket?.close();
163
+ this.socket = null;
164
+ this.resetPort();
165
+ }
151
166
  });
152
- this.managed = true;
153
- process.once("exit", () => this.child?.kill());
154
- for (const sig of ["SIGINT", "SIGTERM"]) {
155
- process.once(sig, () => {
156
- this.child?.kill();
157
- process.exit(0);
167
+ process.once("exit", () => child.kill());
168
+ return new Promise((resolve, reject) => {
169
+ const timer = setTimeout(() => {
170
+ child.kill();
171
+ reject(new Error("The built-in CNCjs did not start within 30 seconds."));
172
+ }, 30000);
173
+ child.on("message", (m) => {
174
+ if (m?.type === "ready") {
175
+ clearTimeout(timer);
176
+ this.url = `http://127.0.0.1:${m.port}`;
177
+ this.version = m.version ?? manifest.upstream?.version ?? null;
178
+ console.error(`[anc] CNCjs ${this.version} engine on ${this.url}`);
179
+ resolve();
180
+ }
181
+ else if (m?.type === "error") {
182
+ clearTimeout(timer);
183
+ reject(new Error(`The built-in CNCjs could not start: ${m.message}`));
184
+ }
158
185
  });
159
- }
160
- for (let i = 0; i < 60; i++) {
161
- if (await reachable(CNCJS_URL))
162
- return;
163
- await sleep(500);
164
- }
165
- throw new Error("The bundled CNCjs did not start within 30 seconds.");
186
+ child.on("exit", (code) => {
187
+ clearTimeout(timer);
188
+ reject(new Error(`The built-in CNCjs stopped while starting (exit ${code}).`));
189
+ });
190
+ });
166
191
  }
167
192
  connect(token) {
193
+ const url = this.url;
168
194
  return new Promise((resolve, reject) => {
169
- const socket = io(CNCJS_URL, { query: `token=${token}`, reconnection: true });
195
+ const socket = io(url, { query: `token=${token}`, reconnection: true });
170
196
  const timer = setTimeout(() => {
171
197
  socket.close();
172
- reject(new Error(`Timed out connecting to CNCjs at ${CNCJS_URL}`));
198
+ reject(new Error(`Timed out connecting to the built-in CNCjs at ${url}`));
173
199
  }, 10000);
174
200
  socket.on("connect", () => {
175
201
  clearTimeout(timer);
@@ -188,6 +214,19 @@ class CNCjsLink {
188
214
  this.listen(socket);
189
215
  });
190
216
  }
217
+ resetPort() {
218
+ if (this.workflow !== "idle")
219
+ this.endRun();
220
+ this.port = null;
221
+ this.controller = null;
222
+ this.baudrate = null;
223
+ this.status = null;
224
+ this.settings = null;
225
+ this.firmware = null;
226
+ this.parameters = null;
227
+ this.sender = null;
228
+ this.workflow = "idle";
229
+ }
191
230
  listen(socket) {
192
231
  socket.on("serialport:list", (ports) => {
193
232
  this.ports = ports ?? [];
@@ -197,19 +236,7 @@ class CNCjsLink {
197
236
  this.controller = o?.controllerType ?? null;
198
237
  this.baudrate = typeof o?.baudrate === "number" ? o.baudrate : null;
199
238
  });
200
- socket.on("serialport:close", () => {
201
- if (this.workflow !== "idle")
202
- this.endRun();
203
- this.port = null;
204
- this.controller = null;
205
- this.baudrate = null;
206
- this.status = null;
207
- this.settings = null;
208
- this.firmware = null;
209
- this.parameters = null;
210
- this.sender = null;
211
- this.workflow = "idle";
212
- });
239
+ socket.on("serialport:close", () => this.resetPort());
213
240
  // Grbl's $$ settings (travel, rapid rates, homing), which CNCjs asks for on connect.
214
241
  socket.on("controller:settings", (_type, s) => {
215
242
  const values = s?.settings && typeof s.settings === "object" ? s.settings : null;
@@ -295,7 +322,7 @@ class CNCjsLink {
295
322
  return {
296
323
  link,
297
324
  state,
298
- cncjs: { url: CNCJS_URL, managed: this.managed, connected: this.connected, error: this.error },
325
+ cncjs: { version: this.version, connected: this.connected, error: this.error },
299
326
  port: this.port,
300
327
  controller: this.controller,
301
328
  baudrate: this.baudrate,
@@ -319,12 +346,13 @@ class CNCjsLink {
319
346
  }
320
347
  async openPort(port, baudrate, controllerType) {
321
348
  await this.ensure();
349
+ this.openErrors.delete(port);
322
350
  await new Promise((resolve, reject) => {
323
351
  this.socket.emit("open", port, { controllerType, baudrate }, (err) => {
324
- if (err)
325
- reject(new Error(`CNCjs could not open ${port}: ${JSON.stringify(err)}`));
326
- else
327
- resolve();
352
+ if (!err)
353
+ return resolve();
354
+ // The error object doesn't survive socket.io; the engine logs its message.
355
+ setTimeout(() => reject(new Error(openErrorMessage(port, this.openErrors.get(port) ?? err?.message ?? null))), 200);
328
356
  });
329
357
  });
330
358
  }
@@ -357,28 +385,24 @@ class CNCjsLink {
357
385
  return this.port;
358
386
  }
359
387
  }
360
- function accessToken(configFile) {
361
- if (process.env.CNCJS_TOKEN)
362
- return process.env.CNCJS_TOKEN;
388
+ function accessToken() {
363
389
  let secret;
364
390
  try {
365
- secret = JSON.parse(fs.readFileSync(configFile, "utf-8")).secret;
391
+ secret = JSON.parse(fs.readFileSync(MANAGED_CONFIG, "utf-8")).secret;
366
392
  }
367
393
  catch {
368
- throw new Error(`Could not read the CNCjs secret from ${configFile}. Set CNCJS_CONFIG or CNCJS_TOKEN.`);
394
+ throw new Error(`Could not read the CNCjs secret from ${MANAGED_CONFIG}.`);
369
395
  }
370
396
  if (!secret)
371
- throw new Error(`${configFile} has no "secret".`);
397
+ throw new Error(`${MANAGED_CONFIG} has no "secret".`);
372
398
  return jwt.sign({ id: "", name: "anc" }, secret, { expiresIn: "30d" });
373
399
  }
374
- async function reachable(url) {
375
- try {
376
- const res = await fetch(url, { signal: AbortSignal.timeout(1500) });
377
- return res.status < 500;
378
- }
379
- catch {
380
- return false;
400
+ /** Says plainly when another program holds the port, rather than sharing it. */
401
+ export function openErrorMessage(port, detail) {
402
+ if (detail && /access denied|resource busy|cannot lock|ebusy|in use/i.test(detail)) {
403
+ return `${port} is in use by another program (another CNCjs, a G-code sender, or a serial monitor). Close that program and try again. (${detail})`;
381
404
  }
405
+ return `Could not open ${port}${detail ? `: ${detail}` : "."}`;
382
406
  }
383
407
  function toVec(v) {
384
408
  if (!v)
@@ -6,10 +6,12 @@ import path from "node:path";
6
6
  import { z } from "zod";
7
7
  import { currentJob, deleteJobFile, jobFileInfo, jobSummary, jobsDir, listJobFiles, loadJobFile, loadJobFromCncjs, loadJobText, parsedLineAt, } from "./jobs.js";
8
8
  import { PRODUCT, ROOT, VERSION } from "./product.js";
9
- import { CNCJS_URL, cncjs } from "./cncjs.js";
9
+ import { cncjs } from "./cncjs.js";
10
10
  import { forgetRun, lastRun, recordRun } from "./history.js";
11
11
  import { analyzeGcode } from "./analysis.js";
12
12
  import { listMachines, machineName, recordMachine, recordMachineRun, renameMachine } from "./machines.js";
13
+ import { camEngine, camJobInfo, camMachine, cancelCamJob, inspectModel, listCamJobs, listModels, listTools, removeTool, saveTool, startGenerate, waitForCamJob, } from "./cam.js";
14
+ import { findSettings, importTools } from "./settings-import.js";
13
15
  // The viewer follows whatever program CNCjs has loaded, including files
14
16
  // loaded from the CNCjs interface itself.
15
17
  cncjs.onGcodeLoad((name, gcode) => loadJobFromCncjs(name, gcode));
@@ -115,6 +117,40 @@ function describeMachine(m, live) {
115
117
  lastSeen: live ? new Date().toISOString() : m.lastSeen,
116
118
  };
117
119
  }
120
+ /** The machine CAM plans for: the connected one, else the one seen most recently. */
121
+ function camMachineNow() {
122
+ const live = cncjs.controllerSettings();
123
+ const m = cncjs.snapshot();
124
+ const record = (m.port && listMachines().find((r) => r.port === m.port)) || listMachines()[0];
125
+ return { ...camMachine(live?.values ?? record?.settings), name: record ? machineName(record) : null };
126
+ }
127
+ /** A finished generation, with the job check's headline for the new file. */
128
+ async function camResult(job) {
129
+ if (job.state !== "done" || !job.file)
130
+ return job;
131
+ try {
132
+ const mc = machineContext();
133
+ const info = await jobFileInfo(job.file, mc.context, mc.key);
134
+ return { ...job, check: info.check, bounds: info.bounds, next: "Show it with anc_show_job (file). Nothing has been loaded into CNCjs." };
135
+ }
136
+ catch {
137
+ return job;
138
+ }
139
+ }
140
+ const OPERATION = z.object({
141
+ type: z.enum(["rough", "outline", "contour"]).describe("rough: clear material in flat layers (stepDown) down to the part. outline: cut along the part's edges, layer by layer (side outside to cut it out). contour: 3D finishing pass over the surface (best with a ball end mill)."),
142
+ tool: z.number().int().describe("Tool id from anc_cam_settings / anc_save_tool. One tool per job."),
143
+ stepDown: z.number().positive().optional().describe("mm per layer (rough, outline)"),
144
+ stepOver: z.number().min(0.05).max(1).optional().describe("Fraction of the tool diameter between passes (rough, contour), e.g. 0.4"),
145
+ feed: z.number().positive().describe("Cutting feed, mm/min"),
146
+ plunge: z.number().positive().optional().describe("Plunge feed, mm/min (rough, outline)"),
147
+ spindle: z.number().positive().describe("Spindle speed, rpm"),
148
+ leave: z.number().min(0).optional().describe("rough: mm to leave on walls for a finishing pass; contour: mm to leave on the surface"),
149
+ area: z.enum(["all", "inside"]).optional().describe("rough: clear all stock around the part (default), or only inside its outline"),
150
+ side: z.enum(["outside", "inside"]).optional().describe("outline: outside edge (default) or inside edges"),
151
+ axis: z.enum(["X", "Y"]).optional().describe("contour: direction of passes (default X)"),
152
+ direction: z.enum(["climb", "conventional"]).optional().describe("Milling direction (default climb)"),
153
+ });
118
154
  function fail(message) {
119
155
  return { content: [{ type: "text", text: message }], isError: true };
120
156
  }
@@ -295,6 +331,194 @@ export function createServer() {
295
331
  return fail(e.message);
296
332
  }
297
333
  });
334
+ // ---- CAM: 3D models to G-code with the built-in Kiri:Moto. Generating only
335
+ // writes a job file; nothing here loads it into CNCjs or moves the machine.
336
+ registerAppTool(server, "anc_list_models", {
337
+ title: "List 3D models",
338
+ description: "List the 3D models (STL files) in the Agentic Numerical Control jobs folder that can be turned into G-code. " +
339
+ "The person adds models by putting STL files in that folder. Each comes with its size in the file's own units, " +
340
+ "the units the person gave when a job was last made from it, and the jobs made from it. Reads only.",
341
+ inputSchema: z.object({
342
+ preview: z.boolean().optional().describe("Include a coarse top view of each model, for the website's Models page"),
343
+ }),
344
+ annotations: { readOnlyHint: true },
345
+ _meta: { ui: { visibility: ["model", "app"] } },
346
+ }, async ({ preview }) => {
347
+ try {
348
+ return json({ computer: os.hostname(), folder: jobsDir(), models: listModels({ preview }) });
349
+ }
350
+ catch (e) {
351
+ return fail(e.message);
352
+ }
353
+ });
354
+ registerAppTool(server, "anc_inspect_model", {
355
+ title: "Inspect 3D model",
356
+ description: "Size and triangle count of an STL model, read by the CAM engine. STL has no units, so the size is given both as millimetres and as inches: " +
357
+ "confirm with the person which is right before generating. Reads only.",
358
+ inputSchema: z.object({ file: z.string().describe("STL file name from anc_list_models") }),
359
+ annotations: { readOnlyHint: true },
360
+ _meta: { ui: { visibility: ["model", "app"] } },
361
+ }, async ({ file }) => {
362
+ try {
363
+ return json(await inspectModel(file));
364
+ }
365
+ catch (e) {
366
+ return fail(e.message);
367
+ }
368
+ });
369
+ registerAppTool(server, "anc_cam_settings", {
370
+ title: "CAM settings",
371
+ description: "What G-code generation will use: the machine (work area, spindle maximum, feed limits, from the controller when it has reported them), " +
372
+ "the person's tool library, the operations available, how stock and zero are set, and the CAM engine version. Reads only.",
373
+ inputSchema: z.object({}),
374
+ annotations: { readOnlyHint: true },
375
+ _meta: { ui: { visibility: ["model", "app"] } },
376
+ }, async () => {
377
+ return json({
378
+ engine: camEngine(),
379
+ machine: camMachineNow(),
380
+ output: "Grbl / grblHAL G-code in mm: G21 G90, spindle on with M3, off with M5, no M6 tool changes.",
381
+ tools: listTools(),
382
+ operations: {
383
+ rough: "Clears material in flat layers. Needs tool, stepDown, stepOver, feed, plunge, spindle; optional leave, area.",
384
+ outline: "Cuts along the part's edges layer by layer; side outside cuts the part out. Needs tool, stepDown, feed, plunge, spindle; optional side.",
385
+ contour: "3D finishing over the surface, usually after rough. Needs tool, stepOver, feed, spindle; optional axis, leave.",
386
+ },
387
+ placement: "The part sits with its top at the top of the stock. Z zero is the top of the stock; X/Y zero is the stock's front-left corner (origin front-left, default) or its centre (origin center). " +
388
+ "Stock defaults to the part plus 10 mm in X and Y, and the part's height.",
389
+ limits: "One tool per job. STL models only for now.",
390
+ recentGenerations: listCamJobs(),
391
+ });
392
+ });
393
+ registerAppTool(server, "anc_save_tool", {
394
+ title: "Save cutting tool",
395
+ description: "Add a cutting tool to the person's tool library, or change one (pass its id). Sizes in mm: convert inch tools (1/8 in = 3.175 mm). " +
396
+ "Ask the person for the tool's real measurements rather than guessing.",
397
+ inputSchema: z.object({
398
+ id: z.number().int().optional().describe("Existing tool to change; leave out to add a new one"),
399
+ name: z.string().max(60).describe("The person's name for it, e.g. '3 mm 2-flute flat'"),
400
+ type: z.enum(["flat", "ball", "vbit", "drill"]),
401
+ diameter: z.number().positive().describe("Cutting diameter, mm"),
402
+ fluteLength: z.number().positive().describe("Length of the cutting edge, mm"),
403
+ shankDiameter: z.number().positive().optional().describe("Shank diameter, mm (defaults to the cutting diameter)"),
404
+ angle: z.number().positive().optional().describe("V-bit included angle, degrees"),
405
+ number: z.number().int().positive().optional().describe("Tool number (T word), if the machine has a tool changer"),
406
+ }),
407
+ _meta: { ui: { visibility: ["model", "app"] } },
408
+ }, async (input) => {
409
+ try {
410
+ return json({ saved: saveTool(input), tools: listTools() });
411
+ }
412
+ catch (e) {
413
+ return fail(e.message);
414
+ }
415
+ });
416
+ registerAppTool(server, "anc_delete_tool", {
417
+ title: "Delete cutting tool",
418
+ description: "Remove a tool from the person's tool library.",
419
+ inputSchema: z.object({ id: z.number().int() }),
420
+ annotations: { destructiveHint: true },
421
+ _meta: { ui: { visibility: ["model", "app"] } },
422
+ }, async ({ id }) => {
423
+ try {
424
+ removeTool(id);
425
+ return json({ deleted: id, tools: listTools() });
426
+ }
427
+ catch (e) {
428
+ return fail(e.message);
429
+ }
430
+ });
431
+ registerAppTool(server, "anc_find_settings", {
432
+ title: "Find settings from other CNC software",
433
+ description: "Look on this computer for settings from other CNC software that could be imported: Kiri:Moto tool exports (.km, .b64), Fusion tool library exports (.json) and CNCjs's ~/.cncrc. " +
434
+ "Searches only ~/.cncrc and, one level deep, Downloads, Documents, Desktop, the jobs folder and Fusion's local libraries. Reads only; changes nothing. " +
435
+ "Show the person what was found and ask which tools to import before calling anc_import_tools.",
436
+ inputSchema: z.object({}),
437
+ annotations: { readOnlyHint: true },
438
+ _meta: { ui: { visibility: ["model", "app"] } },
439
+ }, async () => json(findSettings()));
440
+ registerAppTool(server, "anc_import_tools", {
441
+ title: "Import cutting tools",
442
+ description: "Add tools from a source found by anc_find_settings to the person's tool library, after they have agreed. Only adds: tools already in the library are left as they are, and nothing is replaced or deleted.",
443
+ inputSchema: z.object({
444
+ source: z.string().describe("Source id from anc_find_settings"),
445
+ pick: z.array(z.number().int().min(0)).optional().describe("Which of the source's tools to add (0-based positions in its tools list); leave out for all"),
446
+ }),
447
+ _meta: { ui: { visibility: ["model", "app"] } },
448
+ }, async ({ source, pick }) => {
449
+ try {
450
+ return json(importTools(source, pick));
451
+ }
452
+ catch (e) {
453
+ return fail(e.message);
454
+ }
455
+ });
456
+ registerAppTool(server, "anc_generate_gcode", {
457
+ title: "Generate G-code from a 3D model",
458
+ description: "Make a G-code job from an STL model with the built-in Kiri:Moto CAM engine, and save it in the jobs folder as a new file. " +
459
+ "Before calling: confirm the model's units with the person (anc_inspect_model), the stock size, where they will set zero, and the tool and its feeds and speeds. " +
460
+ "Waits up to about 40 seconds; a longer job keeps going and anc_cam_status reports it. " +
461
+ "Never loads the job into CNCjs or moves the machine: show it with anc_show_job for the person to review.",
462
+ inputSchema: z.object({
463
+ model: z.string().describe("STL file name from anc_list_models"),
464
+ units: z.enum(["mm", "in"]).describe("Units the model was drawn in, as the person confirmed"),
465
+ stock: z
466
+ .object({ x: z.number().positive(), y: z.number().positive(), z: z.number().positive() })
467
+ .optional()
468
+ .describe("Stock size in mm; at least the part's size"),
469
+ origin: z.enum(["front-left", "center"]).optional().describe("Where X/Y zero is on the stock (Z zero is always the top of the stock)"),
470
+ operations: z.array(OPERATION).min(1).max(8).describe("Operations in order, all with the same tool"),
471
+ name: z.string().max(80).optional().describe("Job file name (defaults to the model's name)"),
472
+ }),
473
+ _meta: {
474
+ ui: { visibility: ["model", "app"] },
475
+ "openai/toolInvocation/invoking": "Generating toolpaths…",
476
+ "openai/toolInvocation/invoked": "Toolpaths generated",
477
+ },
478
+ }, async (req) => {
479
+ try {
480
+ const started = await startGenerate(req, camMachineNow());
481
+ return json(await camResult(await waitForCamJob(started.id, 40000)));
482
+ }
483
+ catch (e) {
484
+ return fail(e.message);
485
+ }
486
+ });
487
+ registerAppTool(server, "anc_cam_status", {
488
+ title: "G-code generation status",
489
+ description: "Progress of a G-code generation started with anc_generate_gcode: running (with progress 0-1), done (with the new job file), failed (with the reason) or cancelled. " +
490
+ "Waits up to about 20 seconds for a running one to finish. Without id, lists recent generations. Pass file instead to see how a generated job was made.",
491
+ inputSchema: z.object({
492
+ id: z.string().optional(),
493
+ file: z.string().optional().describe("A generated job file: returns its model, units, stock, tool, operations and engine version"),
494
+ }),
495
+ annotations: { readOnlyHint: true },
496
+ _meta: { ui: { visibility: ["model", "app"] } },
497
+ }, async ({ id, file }) => {
498
+ try {
499
+ if (file)
500
+ return json({ file, generatedWith: camJobInfo(file) });
501
+ if (!id)
502
+ return json({ generations: listCamJobs() });
503
+ return json(await camResult(await waitForCamJob(id, 20000)));
504
+ }
505
+ catch (e) {
506
+ return fail(e.message);
507
+ }
508
+ });
509
+ registerAppTool(server, "anc_cam_cancel", {
510
+ title: "Cancel G-code generation",
511
+ description: "Stop a G-code generation that is still running. Nothing is saved.",
512
+ inputSchema: z.object({ id: z.string() }),
513
+ _meta: { ui: { visibility: ["model", "app"] } },
514
+ }, async ({ id }) => {
515
+ try {
516
+ return json(cancelCamJob(id));
517
+ }
518
+ catch (e) {
519
+ return fail(e.message);
520
+ }
521
+ });
298
522
  // ---- App-only tools: the viewer and the Jobs page call these directly, without a model turn.
299
523
  registerAppTool(server, "anc_delete_job", {
300
524
  title: "Delete job file",
@@ -346,7 +570,6 @@ export function createServer() {
346
570
  }
347
571
  : null,
348
572
  machineName: machine.port ? machineName(listMachines().find((r) => r.port === machine.port) ?? { name: null, port: machine.port, firmware: null, controller: machine.controller }) : null,
349
- cncjsUrl: CNCJS_URL,
350
573
  version: VERSION,
351
574
  serverTime: new Date().toISOString(),
352
575
  });