dsh-cad 0.12.2 → 0.13.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/lib/index.js CHANGED
@@ -9,6 +9,7 @@ import { createFreeCadTool } from './tools/cad-freecad.js';
9
9
  import { createFusionTool } from './tools/cad-fusion.js';
10
10
  import { createOnshapeTool } from './tools/cad-onshape.js';
11
11
  import { createCadImageTool } from './tools/cad-image.js';
12
+ import { createCadScriptTool } from './tools/cad-script.js';
12
13
  export const name = 'dsh-cad';
13
14
  /** Hard dependencies: the tool registry. */
14
15
  export const inject = ['tools'];
@@ -18,23 +19,39 @@ export function apply(ctx, config = {}) {
18
19
  const binStore = new BinarySceneStore(root);
19
20
  const registry = new DocumentRegistry(process.cwd());
20
21
  const workspaceRoot = process.cwd();
21
- let routeRegistered = false;
22
+ // The routes live on the host's shared webServer, which outlives this
23
+ // plugin instance: hosts that hot-reload (the desktop runtime) re-run
24
+ // apply() in the same process, and the server throws on duplicate
25
+ // (kind, path). Every register disposer must therefore run on disposal —
26
+ // leaking them makes the next apply() throw and breaks every
27
+ // viewer-dependent tool call until a process restart.
28
+ let routeDisposers = [];
22
29
  /** Idempotently register the scene routes; returns the JSON base or null. */
23
30
  const ensureSceneRoute = () => {
24
- if (routeRegistered)
31
+ if (routeDisposers.length > 0)
25
32
  return '/dsh-cad/scene';
26
33
  const scope = ctx;
27
34
  const server = (scope.get?.('webServer') ?? scope.get?.('httpServer'));
28
35
  if (server === undefined)
29
36
  return null;
30
- registerSceneRoute(server, store);
31
- registerBinRoute(server, binStore);
32
- registerDemoRoute(server);
33
- registerDocsRoute(server, registry, binStore);
34
- registerDocsDeleteRoute(server, registry);
35
- registerAssemblyRoute(server, registry);
36
- registerFeatureTreeRoute(server, registry);
37
- routeRegistered = true;
37
+ const pending = [];
38
+ try {
39
+ pending.push(registerSceneRoute(server, store));
40
+ pending.push(registerBinRoute(server, binStore));
41
+ pending.push(registerDemoRoute(server));
42
+ pending.push(registerDocsRoute(server, registry, binStore));
43
+ pending.push(registerDocsDeleteRoute(server, registry));
44
+ pending.push(registerAssemblyRoute(server, registry));
45
+ pending.push(registerFeatureTreeRoute(server, registry));
46
+ }
47
+ catch (error) {
48
+ // Roll back the partial batch, or the next attempt trips the duplicate
49
+ // guard on the routes that did register.
50
+ for (const dispose of pending)
51
+ dispose();
52
+ throw error;
53
+ }
54
+ routeDisposers = pending;
38
55
  return '/dsh-cad/scene';
39
56
  };
40
57
  // The demo route backs the CAD editor's startup fetch (before any tool
@@ -43,7 +60,7 @@ export function apply(ctx, config = {}) {
43
60
  // briefly; fiber ordering decides the exact moment.
44
61
  let attempts = 0;
45
62
  const retry = setInterval(() => {
46
- if (routeRegistered || ++attempts > 200) {
63
+ if (routeDisposers.length > 0 || ++attempts > 200) {
47
64
  clearInterval(retry);
48
65
  return;
49
66
  }
@@ -67,6 +84,7 @@ export function apply(ctx, config = {}) {
67
84
  const cadFusion = createFusionTool({ store: binStore, workspaceRoot, ensureSceneRoute });
68
85
  const cadOnshape = createOnshapeTool({ store: binStore, workspaceRoot, ensureSceneRoute });
69
86
  const cadImage = createCadImageTool({ store, workspaceRoot, ensureSceneRoute });
87
+ const cadScript = createCadScriptTool({ workspaceRoot });
70
88
  const disposers = [
71
89
  ctx.tools.register(cadView),
72
90
  ctx.tools.register(cadInfo),
@@ -75,12 +93,16 @@ export function apply(ctx, config = {}) {
75
93
  ctx.tools.register(cadFusion),
76
94
  ctx.tools.register(cadOnshape),
77
95
  ctx.tools.register(cadImage),
96
+ ctx.tools.register(cadScript),
78
97
  ];
79
98
  ctx.effect(() => {
80
99
  return () => {
81
100
  clearInterval(retry);
82
101
  for (const dispose of disposers)
83
102
  dispose();
103
+ for (const dispose of routeDisposers)
104
+ dispose();
105
+ routeDisposers = [];
84
106
  };
85
107
  });
86
108
  }
package/lib/routes.js CHANGED
@@ -169,7 +169,26 @@ export function registerDocsRoute(server, registry, binStore) {
169
169
  // Preview URL only when a published scene exists (memory or mirror).
170
170
  ...(await binStore.has(doc.id) ? { sceneUrl: `${BIN_ROUTE_PATH}/${doc.id}` } : {}),
171
171
  })));
172
- const body = Buffer.from(JSON.stringify({ docs: entries }));
172
+ // The panel's host-independent channel: with ?session=<id>, also
173
+ // report that session's bound (active) document and its versioned
174
+ // scene URL. The resident panel polls this when the conversation
175
+ // meta pipeline is unavailable (host version drift, cold start) —
176
+ // its own route keeps the Part tab live across host UI changes.
177
+ const sessionId = new URL(req.url ?? '/', 'http://localhost').searchParams.get('session');
178
+ let active;
179
+ if (sessionId !== null) {
180
+ const bound = await registry.bindingOf(sessionId);
181
+ const doc = bound === null ? undefined : docs.find((entry) => entry.id === bound);
182
+ if (doc !== undefined) {
183
+ active = {
184
+ id: doc.id,
185
+ name: doc.name,
186
+ version: doc.opCount,
187
+ ...(await binStore.has(doc.id) ? { sceneUrl: `${BIN_ROUTE_PATH}/${doc.id}?v=${doc.opCount}` } : {}),
188
+ };
189
+ }
190
+ }
191
+ const body = Buffer.from(JSON.stringify({ docs: entries, ...(active !== undefined ? { active } : {}) }));
173
192
  res.writeHead(200, {
174
193
  'content-type': 'application/json',
175
194
  'content-length': body.length,
@@ -0,0 +1,226 @@
1
+ /**
2
+ * The `cad_script` tool: deterministic script evaluation for geometry math —
3
+ * generating profile point arrays, arc coordinates, transform matrices —
4
+ * without a CAD kernel round-trip.
5
+ *
6
+ * `language: "js"` runs in-process (Node vm, no child process), which keeps
7
+ * it usable in hosts whose shell tool is sandboxed or broken — the desktop
8
+ * runtime's pwsh can fail outright (exit 0xC0000142), leaving this as the
9
+ * only execution path. `language: "py"` uses a system Python interpreter
10
+ * when the host permits spawning one.
11
+ */
12
+ import { spawn, spawnSync } from 'node:child_process';
13
+ import { mkdir, rm, writeFile } from 'node:fs/promises';
14
+ import path from 'node:path';
15
+ import vm from 'node:vm';
16
+ import { defineTool } from '@deepseek-ai/dsh-tools';
17
+ const JS_DEFAULT_TIMEOUT = 10_000;
18
+ const PY_DEFAULT_TIMEOUT = 30_000;
19
+ const TIMEOUT_CAP = 120_000;
20
+ const OUTPUT_CAP = 20_000;
21
+ function clampTimeout(value, fallback) {
22
+ const n = typeof value === 'number' && Number.isFinite(value) ? value : fallback;
23
+ return Math.min(Math.max(Math.trunc(n), 100), TIMEOUT_CAP);
24
+ }
25
+ function truncate(text) {
26
+ return text.length > OUTPUT_CAP ? `${text.slice(0, OUTPUT_CAP)}\n… (truncated at ${OUTPUT_CAP} chars)` : text;
27
+ }
28
+ function formatValue(value) {
29
+ if (typeof value === 'string')
30
+ return value;
31
+ try {
32
+ return JSON.stringify(value) ?? String(value);
33
+ }
34
+ catch {
35
+ return String(value);
36
+ }
37
+ }
38
+ /** Evaluate synchronous JavaScript in a fresh vm realm; no require/process. */
39
+ function evalJs(code, timeoutMs) {
40
+ const lines = [];
41
+ const push = (...values) => lines.push(values.map(formatValue).join(' '));
42
+ const context = vm.createContext({
43
+ console: { log: push, info: push, warn: push, error: push, debug: push },
44
+ });
45
+ let completion;
46
+ try {
47
+ completion = vm.runInContext(code, context, { timeout: timeoutMs });
48
+ }
49
+ catch (error) {
50
+ const message = error instanceof Error ? error.message : String(error);
51
+ return { ok: false, stdout: truncate(lines.join('\n')), error: message };
52
+ }
53
+ // Thenable check instead of instanceof Promise: the vm realm has its own
54
+ // Promise class, so realm-crossing promises fail instanceof.
55
+ if (completion !== undefined && completion !== null && typeof completion.then === 'function') {
56
+ return { ok: false, stdout: truncate(lines.join('\n')), error: 'asynchronous completion value — keep the script synchronous (no await)' };
57
+ }
58
+ const outcome = { ok: true, stdout: truncate(lines.join('\n')) };
59
+ if (completion !== undefined) {
60
+ try {
61
+ outcome.resultJson = JSON.stringify(completion);
62
+ }
63
+ catch {
64
+ outcome.resultJson = String(completion);
65
+ }
66
+ }
67
+ return outcome;
68
+ }
69
+ let cachedPython;
70
+ /** Probe for a system Python once; null when none is installed/runnable. */
71
+ function findPython() {
72
+ if (cachedPython !== undefined)
73
+ return cachedPython;
74
+ const candidates = [
75
+ { cmd: 'python', args: [] },
76
+ { cmd: 'py', args: ['-3'] },
77
+ { cmd: 'python3', args: [] },
78
+ ];
79
+ for (const candidate of candidates) {
80
+ try {
81
+ const probe = spawnSync(candidate.cmd, [...candidate.args, '--version'], { timeout: 5_000 });
82
+ if (probe.error === undefined && probe.status === 0) {
83
+ cachedPython = candidate;
84
+ return candidate;
85
+ }
86
+ }
87
+ catch {
88
+ /* spawn blocked entirely — try the next candidate */
89
+ }
90
+ }
91
+ cachedPython = null;
92
+ return null;
93
+ }
94
+ /** Run a Python script file, capturing stdout/stderr with a hard timeout. */
95
+ async function evalPy(code, timeoutMs, workspaceRoot) {
96
+ const python = findPython();
97
+ if (python === null) {
98
+ return {
99
+ ok: false,
100
+ stdout: '',
101
+ error: 'no runnable Python interpreter (python / py / python3 all failed to launch — the host may be blocking process creation). ' +
102
+ 'Retry with language: "js", which runs in-process.',
103
+ };
104
+ }
105
+ const dir = path.join(workspaceRoot, '.dsh-cad', 'tmp');
106
+ await mkdir(dir, { recursive: true });
107
+ const file = path.join(dir, `eval-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.py`);
108
+ await writeFile(file, code, 'utf8');
109
+ try {
110
+ return await new Promise((resolve) => {
111
+ let child;
112
+ try {
113
+ child = spawn(python.cmd, [...python.args, file], { cwd: workspaceRoot });
114
+ }
115
+ catch (error) {
116
+ resolve({ ok: false, stdout: '', error: `failed to launch ${python.cmd}: ${error instanceof Error ? error.message : String(error)} — retry with language: "js"` });
117
+ return;
118
+ }
119
+ let stdout = '';
120
+ let stderr = '';
121
+ child.stdout.on('data', (chunk) => {
122
+ if (stdout.length < OUTPUT_CAP)
123
+ stdout += chunk.toString('utf8');
124
+ });
125
+ child.stderr.on('data', (chunk) => {
126
+ if (stderr.length < OUTPUT_CAP)
127
+ stderr += chunk.toString('utf8');
128
+ });
129
+ const timer = setTimeout(() => {
130
+ child.kill();
131
+ resolve({ ok: false, stdout: truncate(stdout), stderr: truncate(stderr), error: `python timed out after ${timeoutMs} ms` });
132
+ }, timeoutMs);
133
+ child.on('error', (error) => {
134
+ clearTimeout(timer);
135
+ resolve({ ok: false, stdout: truncate(stdout), error: `failed to launch ${python.cmd}: ${error.message} — retry with language: "js"` });
136
+ });
137
+ child.on('close', (code2, signal) => {
138
+ clearTimeout(timer);
139
+ if (code2 === 0) {
140
+ resolve({ ok: true, stdout: truncate(stdout), ...(stderr !== '' ? { stderr: truncate(stderr) } : {}) });
141
+ }
142
+ else {
143
+ resolve({
144
+ ok: false,
145
+ stdout: truncate(stdout),
146
+ stderr: truncate(stderr),
147
+ error: `python exited with code ${String(code2)}${signal === null ? '' : ` (signal ${signal})`}`,
148
+ });
149
+ }
150
+ });
151
+ });
152
+ }
153
+ finally {
154
+ await rm(file, { force: true });
155
+ }
156
+ }
157
+ /** Build the cad_script tool definition. */
158
+ export function createCadScriptTool(deps) {
159
+ return defineTool({
160
+ name: 'cad_script',
161
+ description: 'Evaluate a short script for deterministic geometry math (profile point arrays, arc coordinates, transform math) — no CAD ' +
162
+ 'kernel involved. language "js" runs in-process (Node vm; no shell or child process needed, so it works even when the host ' +
163
+ 'shell tool is unavailable); the script must be synchronous, its completion value (last expression) is returned as resultJson, ' +
164
+ 'and console.log output is captured. language "py" runs a system Python interpreter (python / py -3 / python3) and captures ' +
165
+ 'stdout/stderr. Prefer this over the host shell for numeric work.',
166
+ parameters: {
167
+ language: {
168
+ type: 'string',
169
+ required: true,
170
+ enum: ['js', 'py'],
171
+ description: 'Script language: "js" (in-process Node vm, always available) or "py" (system Python).',
172
+ },
173
+ code: {
174
+ type: 'string',
175
+ required: true,
176
+ description: 'Script source. js: synchronous, completion value (last expression) becomes resultJson. py: use print() for output.',
177
+ },
178
+ timeoutMs: {
179
+ type: 'number',
180
+ description: `Execution timeout in ms (default ${JS_DEFAULT_TIMEOUT} for js, ${PY_DEFAULT_TIMEOUT} for py; capped at ${TIMEOUT_CAP}).`,
181
+ },
182
+ },
183
+ output: {
184
+ schema: {
185
+ type: 'object',
186
+ additionalProperties: false,
187
+ properties: {
188
+ ok: { type: 'boolean', required: true },
189
+ language: { type: 'string', required: true },
190
+ resultJson: { type: 'string', description: 'JSON-stringified completion value (js only, when defined).' },
191
+ stdout: { type: 'string', required: true, description: 'Captured console.log (js) or stdout (py).' },
192
+ stderr: { type: 'string', description: 'Captured stderr (py only, when non-empty).' },
193
+ error: { type: 'string', description: 'Failure reason when ok is false.' },
194
+ },
195
+ },
196
+ render: (_args, value) => {
197
+ const lines = [`eval (${value.language}) — ${value.ok ? 'ok' : 'failed'}`];
198
+ if (value.resultJson !== undefined)
199
+ lines.push(`result: ${value.resultJson}`);
200
+ if (value.stdout !== '')
201
+ lines.push(value.stdout);
202
+ if (value.stderr !== undefined)
203
+ lines.push(`stderr: ${value.stderr}`);
204
+ if (value.error !== undefined)
205
+ lines.push(`error: ${value.error}`);
206
+ return [{ type: 'text', text: lines.join('\n') }];
207
+ },
208
+ },
209
+ timeoutMs: TIMEOUT_CAP + 15_000,
210
+ isConcurrencySafe: () => true,
211
+ async execute(args) {
212
+ const timeoutMs = clampTimeout(args.timeoutMs, args.language === 'py' ? PY_DEFAULT_TIMEOUT : JS_DEFAULT_TIMEOUT);
213
+ const outcome = args.language === 'py' ? await evalPy(args.code, timeoutMs, deps.workspaceRoot) : evalJs(args.code, timeoutMs);
214
+ return {
215
+ ok: outcome.ok,
216
+ language: args.language,
217
+ ...(outcome.resultJson === undefined ? {} : { resultJson: outcome.resultJson }),
218
+ stdout: outcome.stdout,
219
+ ...(outcome.stderr === undefined ? {} : { stderr: outcome.stderr }),
220
+ ...(outcome.error === undefined ? {} : { error: outcome.error }),
221
+ };
222
+ },
223
+ presentCall: (args) => ({ card: 'generic', title: `CAD script (${String(args.language)})`, kind: 'other' }),
224
+ presentResult: () => ({ card: 'generic', title: 'CAD script' }),
225
+ });
226
+ }
@@ -0,0 +1,6 @@
1
+ import type { ToolDefinition } from '@deepseek-ai/dsh-tools';
2
+ export interface CadScriptToolDeps {
3
+ workspaceRoot: string;
4
+ }
5
+ /** Build the cad_script tool definition. */
6
+ export declare function createCadScriptTool(deps: CadScriptToolDeps): ToolDefinition;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-cad",
3
3
  "description": "CAD visualization plugin for DeepSeek Harness: cad_view / cad_info tools with an embedded 3D (STL/OBJ/STEP/IGES) and 2D (DXF/SVG) viewer card in the Web UI",
4
- "version": "0.12.2",
4
+ "version": "0.13.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "LAU-MARS",