@huaqiu/dsh-eda-host 0.3.24 → 0.4.1

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.mjs CHANGED
@@ -1,14 +1,24 @@
1
1
  import { getLogger } from "@huaqiu/dsh-plugin-log";
2
2
  import { defineTool } from "@deepseek-ai/dsh-tools";
3
+ const DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
3
4
  const SCOPE_ROUTE = {
4
5
  project: "/project",
5
6
  selection: "/selection",
6
7
  "active-page": "/active-page"
7
8
  };
8
9
  function resolveEdaHostConfig(config, env = process.env) {
10
+ const baseUrl = config?.hqEdgeBaseUrl ?? env.HQ_EDGE_BASE_URL ?? "";
11
+ const pathPrefix = config?.netlistPathPrefix ?? env.HQ_EDGE_NETLIST_PATH ?? "/api/v1/netlist";
12
+ const pcbSelectionPrefix = config?.pcbSelectionPathPrefix ?? env.HQ_EDGE_PCB_SELECTION_PATH ?? "/api/v1/pcb-selection";
13
+ const hostPrefix = config?.hostPathPrefix ?? env.HQ_EDGE_HOST_PATH ?? "/api/v1/host";
14
+ const timeoutRaw = config?.requestTimeoutMs ?? env.HQ_EDGE_REQUEST_TIMEOUT_MS;
15
+ const timeout = Number.parseInt(String(timeoutRaw ?? ""), 10);
9
16
  return {
10
- hqEdgeBaseUrl: config?.hqEdgeBaseUrl ?? env.HQ_EDGE_BASE_URL ?? "",
11
- netlistPathPrefix: config?.netlistPathPrefix ?? env.HQ_EDGE_NETLIST_PATH ?? "/api/v1/netlist"
17
+ hqEdgeBaseUrl: baseUrl,
18
+ netlistPathPrefix: pathPrefix,
19
+ pcbSelectionPathPrefix: pcbSelectionPrefix,
20
+ hostPathPrefix: hostPrefix,
21
+ requestTimeoutMs: Number.isFinite(timeout) && timeout > 0 ? timeout : DEFAULT_REQUEST_TIMEOUT_MS
12
22
  };
13
23
  }
14
24
  /** True when a host base URL is available (host mode). */
@@ -19,6 +29,18 @@ function hasHost(config) {
19
29
  function netlistUrlOf(config, scope) {
20
30
  return `${(config.hqEdgeBaseUrl ?? "").replace(/\/+$/, "")}/${(config.netlistPathPrefix ?? "/api/v1/netlist").replace(/^\/+|\/+$/g, "")}${SCOPE_ROUTE[scope]}`;
21
31
  }
32
+ /** Build the absolute URL for the PCB selection route. */
33
+ function pcbSelectionUrlOf(config) {
34
+ return `${(config.hqEdgeBaseUrl ?? "").replace(/\/+$/, "")}/${(config.pcbSelectionPathPrefix ?? "/api/v1/pcb-selection").replace(/^\/+|\/+$/g, "")}`;
35
+ }
36
+ const HOST_ROUTE = {
37
+ info: "/info",
38
+ capabilities: "/capabilities"
39
+ };
40
+ /** Build the absolute URL for one host discovery route. */
41
+ function hostUrlOf(config, route) {
42
+ return `${(config.hqEdgeBaseUrl ?? "").replace(/\/+$/, "")}/${(config.hostPathPrefix ?? "/api/v1/host").replace(/^\/+|\/+$/g, "")}${HOST_ROUTE[route]}`;
43
+ }
22
44
  //#endregion
23
45
  //#region src/types.ts
24
46
  var NetlistError = class extends Error {
@@ -32,38 +54,128 @@ var NetlistError = class extends Error {
32
54
  //#endregion
33
55
  //#region src/client.ts
34
56
  /**
35
- * Netlist transport for `@huaqiu/dsh-eda-host`.
57
+ * Transport for `@huaqiu/dsh-eda-host`.
36
58
  *
37
59
  * DSH → dsh-eda-host → hq-edge → EDA Host is the ONLY production path. This
38
- * module fetches the semantic netlist from the hq-edge netlist router and
39
- * maps the HTTP status back to the semantic gRPC error categories. It never
40
- * parses schematic files and never touches KiCad.
60
+ * module fetches the semantic netlist and the EDA-independent host information
61
+ * from hq-edge, maps HTTP statuses back to the semantic gRPC error categories,
62
+ * and enforces the single end-to-end request budget. It never parses schematic
63
+ * files and never touches KiCad.
41
64
  *
42
65
  * @module
43
66
  */
44
- /** HTTP status → semantic error kind (see routes/netlist.ts on hq-edge). */
67
+ /** HTTP status → semantic error kind (see routes/edaHostStatus.ts on hq-edge). */
45
68
  function statusToKind(status) {
46
69
  if (status === 412) return "FAILED_PRECONDITION";
47
70
  if (status === 501) return "UNIMPLEMENTED";
48
71
  if (status === 503) return "UNAVAILABLE";
72
+ if (status === 504) return "DEADLINE_EXCEEDED";
49
73
  return "INTERNAL";
50
74
  }
75
+ /** True when a thrown fetch error is an abort/timeout rather than a transport error. */
76
+ function isAbortError(err) {
77
+ const name = err?.name;
78
+ return name === "AbortError" || name === "TimeoutError";
79
+ }
80
+ /**
81
+ * Combine an optional caller signal with the plugin's own budget.
82
+ *
83
+ * `AbortSignal.any` is not available on every Node version DSH may run on, so
84
+ * fall back to whichever signal exists — the budget is always present, which is
85
+ * what guarantees no request can wait indefinitely.
86
+ */
87
+ function resolveSignal(deadlineMs, caller) {
88
+ const signals = [caller, deadlineMs > 0 ? AbortSignal.timeout(deadlineMs) : void 0].filter((s) => Boolean(s));
89
+ if (signals.length === 0) return void 0;
90
+ if (signals.length === 1) return signals[0];
91
+ const anyFn = AbortSignal.any;
92
+ return typeof anyFn === "function" ? anyFn.call(AbortSignal, signals) : signals[0];
93
+ }
94
+ /**
95
+ * Extract the semantic `SchematicNetlist` from an hq-edge netlist body.
96
+ *
97
+ * hq-edge now emits a single-level body: `{ netlist: { components, nets } }`.
98
+ * Older hq-edge builds serialized the protobuf envelope
99
+ * (`GetNetListResponse.oneof result`), which produced a second `netlist` level
100
+ * and made every populated design look empty. That legacy shape is unwrapped
101
+ * EXPLICITLY — not silently — and anything else is a hard error, because
102
+ * "malformed" must never masquerade as "empty design".
103
+ */
104
+ function parseNetlistBody(body) {
105
+ if (!body || typeof body !== "object" || Array.isArray(body)) throw new NetlistError("INTERNAL", "eda-host: malformed netlist response from hq-edge");
106
+ let candidate = body.netlist;
107
+ if (candidate && typeof candidate === "object" && !Array.isArray(candidate) && candidate.components === void 0 && typeof candidate.netlist === "object") candidate = candidate.netlist;
108
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) throw new NetlistError("INTERNAL", "eda-host: malformed netlist response from hq-edge");
109
+ const { components, nets } = candidate;
110
+ if (components !== void 0 && !Array.isArray(components)) throw new NetlistError("INTERNAL", "eda-host: netlist.components is not an array");
111
+ if (nets !== void 0 && !Array.isArray(nets)) throw new NetlistError("INTERNAL", "eda-host: netlist.nets is not an array");
112
+ return {
113
+ components: components ?? [],
114
+ nets: nets ?? []
115
+ };
116
+ }
117
+ function str(value) {
118
+ return typeof value === "string" ? value : "";
119
+ }
120
+ /**
121
+ * Extract a complete `EdaHostInfo` from an hq-edge host-info body.
122
+ *
123
+ * `hq.host.v1` has no availability flags, so the only way to distinguish "the
124
+ * host is here" from "the host is not" is whether this call succeeded at all.
125
+ * That makes the *shape* the contract: proto3 JSON omits default-valued fields,
126
+ * so a host that legitimately reports nothing arrives as `{}`. Rather than let
127
+ * a half-empty object reach the agent — where a missing field could be misread
128
+ * as "not available" — absent values are filled with their proto3 defaults.
129
+ *
130
+ * Values that are present are never reinterpreted; unknown `hostType` strings
131
+ * pass through so a newer host cannot be silently downgraded.
132
+ */
133
+ function parseEdaHostInfo(value) {
134
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new NetlistError("INTERNAL", "eda-host: malformed host info response from hq-edge");
135
+ const raw = value;
136
+ const executables = Array.isArray(raw.installation?.executables) ? (raw.installation?.executables).filter((e) => Boolean(e) && typeof e === "object").map((e) => ({
137
+ name: str(e.name),
138
+ path: str(e.path)
139
+ })) : [];
140
+ return {
141
+ identity: {
142
+ hostType: typeof raw.identity?.hostType === "string" ? raw.identity.hostType : "EDA_HOST_TYPE_UNSPECIFIED",
143
+ hostName: str(raw.identity?.hostName),
144
+ version: str(raw.identity?.version)
145
+ },
146
+ installation: {
147
+ applicationPath: str(raw.installation?.applicationPath),
148
+ executables
149
+ }
150
+ };
151
+ }
51
152
  function createEdaHostClient(config, deps = {}) {
52
153
  const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
53
- async function fetchScope(scope) {
154
+ /**
155
+ * Resolve the host endpoint per request. A resolver (ctx.hqEdge) wins over
156
+ * the static config/env value; if neither yields a URL we degrade to the
157
+ * same clear FAILED_PRECONDITION the standalone install path uses.
158
+ */
159
+ function resolveConfig() {
54
160
  const baseUrl = deps.baseUrlResolver?.()?.trim() ?? config.hqEdgeBaseUrl?.trim() ?? "";
55
- if (baseUrl.length === 0) throw new NetlistError("FAILED_PRECONDITION", "eda-host: no hq-edge base URL configured (hqEdgeBaseUrl / HQ_EDGE_BASE_URL) — netlist tools require the hq-edge EDA host bridge.");
56
- const url = netlistUrlOf({
161
+ if (baseUrl.length === 0) throw new NetlistError("FAILED_PRECONDITION", "eda-host: no hq-edge base URL configured (ctx.hqEdge.baseUrl / hqEdgeBaseUrl / HQ_EDGE_BASE_URL) — EDA host tools require the hq-edge bridge.");
162
+ return {
57
163
  ...config,
58
164
  hqEdgeBaseUrl: baseUrl
59
- }, scope);
165
+ };
166
+ }
167
+ /** Perform one GET and return the parsed JSON body, mapping failures. */
168
+ async function getJson(url, options, what) {
169
+ const signal = resolveSignal(config.requestTimeoutMs ?? 3e4, options?.signal);
60
170
  let response;
61
171
  try {
62
172
  response = await fetchImpl(url, {
63
173
  method: "GET",
64
- headers: { Accept: "application/json" }
174
+ headers: { Accept: "application/json" },
175
+ ...signal ? { signal } : {}
65
176
  });
66
177
  } catch (err) {
178
+ if (isAbortError(err)) throw new NetlistError("DEADLINE_EXCEEDED", `eda-host: ${what} request exceeded its time budget at ${url}`);
67
179
  throw new NetlistError("UNAVAILABLE", `eda-host: cannot reach hq-edge at ${url}: ${String(err?.message ?? err)}`);
68
180
  }
69
181
  if (!response.ok) {
@@ -72,19 +184,47 @@ function createEdaHostClient(config, deps = {}) {
72
184
  const body = await response.json();
73
185
  if (typeof body.detail === "string") detail = body.detail;
74
186
  } catch {}
75
- throw new NetlistError(statusToKind(response.status), `eda-host: netlist request failed (${response.status}${detail ? `: ${detail}` : ""})`);
187
+ throw new NetlistError(statusToKind(response.status), `eda-host: ${what} request failed (${response.status}${detail ? `: ${detail}` : ""})`);
76
188
  }
77
- const body = await response.json();
78
- if (!body || typeof body.netlist !== "object" || body.netlist === null) throw new NetlistError("INTERNAL", "eda-host: malformed netlist response from hq-edge");
79
- const netlist = body.netlist;
80
- netlist.components ??= [];
81
- netlist.nets ??= [];
82
- return netlist;
189
+ return response.json();
190
+ }
191
+ async function fetchScope(scope, options) {
192
+ return parseNetlistBody(await getJson(netlistUrlOf(resolveConfig(), scope), options, "netlist"));
193
+ }
194
+ function parsePcbSelection(value) {
195
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new NetlistError("INTERNAL", "eda-host: malformed PCB selection response from hq-edge");
196
+ const asArray = (v) => Array.isArray(v) ? v : [];
197
+ return {
198
+ footprints: asArray(value.footprints),
199
+ pads: asArray(value.pads),
200
+ tracks: asArray(value.tracks),
201
+ arcs: asArray(value.arcs),
202
+ vias: asArray(value.vias),
203
+ zones: asArray(value.zones),
204
+ shapes: asArray(value.shapes),
205
+ texts: asArray(value.texts),
206
+ dimensions: asArray(value.dimensions),
207
+ groups: asArray(value.groups),
208
+ nets: asArray(value.nets)
209
+ };
83
210
  }
84
211
  return {
85
- getSelectionNetlist: () => fetchScope("selection"),
86
- getProjectNetlist: () => fetchScope("project"),
87
- getActivePageNetlist: () => fetchScope("active-page")
212
+ getSelectionNetlist: (options) => fetchScope("selection", options),
213
+ getProjectNetlist: (options) => fetchScope("project", options),
214
+ getActivePageNetlist: (options) => fetchScope("active-page", options),
215
+ getEdaHostInfo: async (options) => {
216
+ const body = await getJson(hostUrlOf(resolveConfig(), "info"), options, "host info");
217
+ if (!body || typeof body.info !== "object" || body.info === null) throw new NetlistError("INTERNAL", "eda-host: malformed host info response from hq-edge");
218
+ return parseEdaHostInfo(body.info);
219
+ },
220
+ getEdaHostCapabilities: async (options) => {
221
+ const body = await getJson(hostUrlOf(resolveConfig(), "capabilities"), options, "host capabilities");
222
+ if (!body || !Array.isArray(body.capabilities)) throw new NetlistError("INTERNAL", "eda-host: malformed host capabilities response from hq-edge");
223
+ return body.capabilities.filter((c) => typeof c === "string");
224
+ },
225
+ getPcbSelection: async (options) => {
226
+ return parsePcbSelection(await getJson(pcbSelectionUrlOf(resolveConfig()), options, "pcb selection"));
227
+ }
88
228
  };
89
229
  }
90
230
  //#endregion
@@ -92,17 +232,23 @@ function createEdaHostClient(config, deps = {}) {
92
232
  /**
93
233
  * Agent tools for `@huaqiu/dsh-eda-host`.
94
234
  *
95
- * Three semantic operations, one per netlist scope:
235
+ * Three semantic netlist operations, one per scope:
96
236
  *
97
237
  * get_project_netlist complete logical netlist of the current project
98
238
  * get_selection_netlist netlist of the currently selected components
99
239
  * get_active_page_netlist netlist of the active schematic page
100
240
  *
101
- * The tools are pure pass-throughs: they call the hq-edge netlist router and
102
- * return the semantic `SchematicNetlist` as lossless JSON. Errors are
103
- * propagated with a semantic `kind` (FAILED_PRECONDITION / UNIMPLEMENTED /
104
- * INTERNAL / UNAVAILABLE) — never converted into a fake empty netlist. A
105
- * valid-but-empty netlist is `ok: true` with empty `components`/`nets`.
241
+ * Two EDA host discovery operations:
242
+ *
243
+ * get_eda_host_info which host, which version, where it is installed
244
+ * get_eda_host_capabilities what the current host can actually do
245
+ *
246
+ * The netlist tools are pure pass-throughs: they call the hq-edge netlist
247
+ * router and return the semantic `SchematicNetlist` as lossless JSON. Errors
248
+ * are propagated with a semantic `kind` (FAILED_PRECONDITION / UNIMPLEMENTED /
249
+ * INTERNAL / UNAVAILABLE / DEADLINE_EXCEEDED) — never converted into a fake
250
+ * empty netlist. A valid-but-empty netlist is `ok: true` with empty
251
+ * `components`/`nets`.
106
252
  *
107
253
  * @module
108
254
  */
@@ -116,12 +262,25 @@ function renderJson(_args, value) {
116
262
  text: JSON.stringify(value)
117
263
  }];
118
264
  }
119
- async function runScope(env, scope) {
265
+ /**
266
+ * Every failure kind, and what the agent should do about it.
267
+ *
268
+ * Shared by all tools so the prompt contract cannot drift between them.
269
+ */
270
+ const ERROR_SEMANTICS = "IMPORTANT SEMANTICS: on ok:false, error.kind distinguishes the cause: \"FAILED_PRECONDITION\" (no EDA host / no live editor — ask the user to open the design in the EDA editor first, then retry), \"UNIMPLEMENTED\" (this capability is not supported by the current host — do NOT retry; report it to the user), \"UNAVAILABLE\" (hq-edge / EDA host unreachable), \"DEADLINE_EXCEEDED\" (the host did not answer in time — retry once, then report), \"INTERNAL\" (host-side failure). Do NOT fabricate data.";
271
+ function failureOf(err) {
272
+ return {
273
+ kind: err instanceof NetlistError ? err.kind : "INTERNAL",
274
+ message: String(err?.message ?? err)
275
+ };
276
+ }
277
+ async function runScope(env, scope, exec) {
278
+ const options = exec?.signal ? { signal: exec.signal } : {};
120
279
  try {
121
280
  let netlist;
122
- if (scope === "project") netlist = await env.client.getProjectNetlist();
123
- else if (scope === "selection") netlist = await env.client.getSelectionNetlist();
124
- else netlist = await env.client.getActivePageNetlist();
281
+ if (scope === "project") netlist = await env.client.getProjectNetlist(options);
282
+ else if (scope === "selection") netlist = await env.client.getSelectionNetlist(options);
283
+ else netlist = await env.client.getActivePageNetlist(options);
125
284
  return {
126
285
  ok: true,
127
286
  scope,
@@ -131,15 +290,12 @@ async function runScope(env, scope) {
131
290
  return {
132
291
  ok: false,
133
292
  scope,
134
- error: {
135
- kind: err instanceof NetlistError ? err.kind : "INTERNAL",
136
- message: String(err?.message ?? err)
137
- }
293
+ error: failureOf(err)
138
294
  };
139
295
  }
140
296
  }
141
297
  function scopeDescription(scope, extra) {
142
- return "Read the current schematic netlist from the EDA host through hq-edge (DSH → dsh-eda-host → hq-edge → EDA host). " + extra + " The result is a semantic netlist JSON: { ok, scope, netlist: { components[], nets[] } }. Each component has referenceDesignators[], value, manufacturerPartNumber, footprint, description and pins[] (pinNumber, pinName, electricalType); each net has name and pinReferences[] (referenceDesignator, pinNumber). IMPORTANT SEMANTICS: ok:true with empty components/nets is a VALID empty design — do not treat it as a failure. On ok:false, error.kind distinguishes the cause: \"FAILED_PRECONDITION\" (no EDA host / no live editor — ask the user to open the design in the EDA editor first, then retry), \"UNIMPLEMENTED\" (this scope is not supported by the current host — do NOT retry; report it to the user), \"UNAVAILABLE\" (hq-edge host unreachable), \"INTERNAL\" (host-side failure). Do NOT fabricate netlist data.";
298
+ return "Read the current schematic netlist from the EDA host through hq-edge (DSH → dsh-eda-host → hq-edge → EDA host). " + extra + " The result is a semantic netlist JSON: { ok, scope, netlist: { components[], nets[] } }. Each component has referenceDesignators[], value, manufacturerPartNumber, footprint, description and pins[] (pinNumber, pinName, electricalType); each net has name and pinReferences[] (referenceDesignator, pinNumber). IMPORTANT: ok:true with empty components/nets is a VALID empty design — do not treat it as a failure. IMPORTANT SEMANTICS: on ok:false, error.kind distinguishes the cause: \"FAILED_PRECONDITION\" (no EDA host / no live editor — ask the user to open the design in the EDA editor first, then retry), \"UNIMPLEMENTED\" (this capability is not supported by the current host — do NOT retry; report it to the user), \"UNAVAILABLE\" (hq-edge / EDA host unreachable), \"DEADLINE_EXCEEDED\" (the host did not answer in time — retry once, then report), \"INTERNAL\" (host-side failure). Do NOT fabricate data.";
143
299
  }
144
300
  function createNetListTools(env) {
145
301
  const mkTool = (scope, name, desc) => defineTool({
@@ -150,14 +306,94 @@ function createNetListTools(env) {
150
306
  schema: { type: "json" },
151
307
  render: renderJson
152
308
  },
153
- async execute(_args, _exec) {
154
- return asJson(await runScope(env, scope));
309
+ async execute(_args, exec) {
310
+ return asJson(await runScope(env, scope, exec));
155
311
  }
156
312
  });
157
313
  return [
158
314
  mkTool("project", "get_project_netlist", scopeDescription("project", "Returns the complete logical netlist for the current project (all sheets, all nets).")),
159
315
  mkTool("selection", "get_selection_netlist", scopeDescription("selection", "Returns the netlist associated with the currently selected schematic components, including the nets they participate in (each net lists every connected pin, not only the selected ones).")),
160
- mkTool("active_page", "get_active_page_netlist", scopeDescription("active_page", "Returns the netlist for the currently active schematic page. NOTE: KiCad host does not implement this scope — expect ok:false with error.kind \"UNIMPLEMENTED\"."))
316
+ mkTool("active_page", "get_active_page_netlist", scopeDescription("active_page", "Returns the netlist for the currently active schematic page. NOTE: the KiCad host does not implement this scope — expect ok:false with error.kind \"UNIMPLEMENTED\". Check get_eda_host_capabilities before relying on it."))
317
+ ];
318
+ }
319
+ /**
320
+ * EDA host discovery tools.
321
+ *
322
+ * These expose what the EDA host ALREADY knows and can ALREADY do — they
323
+ * implement no EDA functionality themselves. A capability being advertised is
324
+ * a claim that the host can provide it, nothing more.
325
+ */
326
+ function createEdaHostTools(env) {
327
+ return [
328
+ defineTool({
329
+ name: "get_eda_host_info",
330
+ description: "Describe the EDA host currently connected through hq-edge (DSH → dsh-eda-host → hq-edge → EDA host). Returns { ok, info: { identity: { hostType, hostName, version }, installation: { applicationPath, executables[]: { name, path } } } }. Use it when you need factual information about the current EDA environment, such as \"what EDA host am I connected to\", \"what version is it\", \"where is it installed\", or \"where is kicad-cli\". The returned information is authoritative host-provided ground truth. " + ERROR_SEMANTICS,
331
+ parameters: {},
332
+ output: {
333
+ schema: { type: "json" },
334
+ render: renderJson
335
+ },
336
+ async execute(_args, exec) {
337
+ try {
338
+ const options = exec?.signal ? { signal: exec.signal } : {};
339
+ return asJson({
340
+ ok: true,
341
+ info: await env.client.getEdaHostInfo(options)
342
+ });
343
+ } catch (err) {
344
+ return asJson({
345
+ ok: false,
346
+ error: failureOf(err)
347
+ });
348
+ }
349
+ }
350
+ }),
351
+ defineTool({
352
+ name: "get_pcb_selection",
353
+ description: "Read the semantic PCB selection from the current PCB editor through hq-edge (DSH → dsh-eda-host → hq-edge → EDA host). Returns { ok, selection: { footprints[], pads[], tracks[], arcs[], vias[], zones[], shapes[], texts[], dimensions[], groups[], nets[] } }. Each footprint has reference, value, footprint, position {x,y} in mm, rotationDeg and pads[] (pin, type, shape, position, widthMm, heightMm, rotationDeg, layer, net{name, code}); tracks have layer, start/end in mm, widthMm, lengthMm and net; vias have layers[], drillMm, viaType and start/end; zones have layer, net and outline segments. Every object carries id — the EDA-host native object identity. IMPORTANT: ok:true with all-empty arrays is a VALID empty selection (nothing selected) — do not treat it as a failure. " + ERROR_SEMANTICS,
354
+ parameters: {},
355
+ output: {
356
+ schema: { type: "json" },
357
+ render: renderJson
358
+ },
359
+ async execute(_args, exec) {
360
+ try {
361
+ const options = exec?.signal ? { signal: exec.signal } : {};
362
+ return asJson({
363
+ ok: true,
364
+ selection: await env.client.getPcbSelection(options)
365
+ });
366
+ } catch (err) {
367
+ return asJson({
368
+ ok: false,
369
+ error: failureOf(err)
370
+ });
371
+ }
372
+ }
373
+ }),
374
+ defineTool({
375
+ name: "get_eda_host_capabilities",
376
+ description: "List the capabilities the CURRENT EDA host provides, using EDA-independent capability identifiers such as EDA_HOST_CAPABILITY_NETLIST, EDA_HOST_CAPABILITY_PCB or EDA_HOST_CAPABILITY_BOM. Returns { ok, capabilities: string[] }. This is DISCOVERY, not execution: it reports capabilities already provided by the connected EDA host. Treat the returned capability list as the authoritative runtime contract for the current host. Do not assume a capability is available merely because the EDA application is generally known to support it. Unknown capability identifiers MUST be treated as unsupported. " + ERROR_SEMANTICS,
377
+ parameters: {},
378
+ output: {
379
+ schema: { type: "json" },
380
+ render: renderJson
381
+ },
382
+ async execute(_args, exec) {
383
+ try {
384
+ const options = exec?.signal ? { signal: exec.signal } : {};
385
+ return asJson({
386
+ ok: true,
387
+ capabilities: await env.client.getEdaHostCapabilities(options)
388
+ });
389
+ } catch (err) {
390
+ return asJson({
391
+ ok: false,
392
+ error: failureOf(err)
393
+ });
394
+ }
395
+ }
396
+ })
161
397
  ];
162
398
  }
163
399
  //#endregion
@@ -182,6 +418,7 @@ const name = "@huaqiu/dsh-eda-host";
182
418
  */
183
419
  const inject = ["hqEdge", "tools"];
184
420
  const log = getLogger("dsh-eda-host");
421
+ log.info("dsh-eda-host: module loaded (waiting for the hqEdge + tools services)");
185
422
  /**
186
423
  * Host plugin body — provide `edaHost` and register the three netlist tools.
187
424
  *
@@ -201,22 +438,26 @@ const log = getLogger("dsh-eda-host");
201
438
  function apply(ctx, config = {}) {
202
439
  if (!ctx.tools || typeof ctx.tools.register !== "function") throw new Error("@huaqiu/dsh-eda-host requires the DSH `tools` service (ctx.tools.register).");
203
440
  const resolved = resolveEdaHostConfig(config);
441
+ const hq = ctx.hqEdge;
442
+ if (!hq || typeof hq.baseUrl !== "string" || hq.baseUrl.trim().length === 0) throw new Error("@huaqiu/dsh-eda-host requires a usable hq-edge context: the edge-bridge plugin did not provide ctx.hqEdge.baseUrl. EDA host tools cannot work without hq-edge — check that the bridge started and that the HQ Edge port is valid.");
204
443
  const getHqEdgeBaseUrl = () => {
205
- const hq = ctx.hqEdge;
206
- return hq?.baseUrl && hq.baseUrl.trim().length > 0 ? hq.baseUrl : void 0;
444
+ const current = ctx.hqEdge;
445
+ return current?.baseUrl && current.baseUrl.trim().length > 0 ? current.baseUrl : void 0;
207
446
  };
208
447
  log.info("applying dsh-eda-host node half", {
209
448
  hasConfigHost: hasHost(resolved),
210
449
  hqEdgeBaseUrlFromConfig: resolved.hqEdgeBaseUrl ?? null,
211
- netlistPathPrefix: resolved.netlistPathPrefix
450
+ netlistPathPrefix: resolved.netlistPathPrefix,
451
+ hostPathPrefix: resolved.hostPathPrefix,
452
+ requestTimeoutMs: resolved.requestTimeoutMs
212
453
  });
213
454
  const client = createEdaHostClient(resolved, { baseUrlResolver: getHqEdgeBaseUrl });
214
455
  ctx.effect(() => ctx.provide("edaHost", client));
215
- const tools = createNetListTools({ client });
456
+ const tools = [...createNetListTools({ client }), ...createEdaHostTools({ client })];
216
457
  const disposers = [];
217
458
  for (const tool of tools) disposers.push(ctx.tools.register(tool));
218
459
  log.info("dsh-eda-host node half ready", {
219
- tools: 3,
460
+ tools: tools.length,
220
461
  configHostMode: hasHost(resolved)
221
462
  });
222
463
  return () => {
@@ -226,4 +467,4 @@ function apply(ctx, config = {}) {
226
467
  };
227
468
  }
228
469
  //#endregion
229
- export { NetlistError, apply, inject, name };
470
+ export { NetlistError, apply, inject, name, parseNetlistBody };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huaqiu/dsh-eda-host",
3
- "version": "0.3.24",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "main": "./lib/index.mjs",
6
6
  "types": "./lib/index.d.mts",
@@ -22,7 +22,7 @@
22
22
  "@deepseek-ai/dsh-tools": "^0.1.0-rc.0"
23
23
  },
24
24
  "dependencies": {
25
- "@huaqiu/dsh-plugin-log": "0.3.24"
25
+ "@huaqiu/dsh-plugin-log": "0.4.1"
26
26
  },
27
27
  "files": [
28
28
  "lib",