@huaqiu/dsh-eda-host 0.3.22 → 0.3.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,16 +1,30 @@
1
1
  # @huaqiu/dsh-eda-host
2
2
 
3
- DSH plugin exposing the current EDA host's schematic netlist to agents through
4
- hq-edge.
3
+ DSH plugin exposing the current EDA host to agents through hq-edge: its
4
+ schematic netlist, and — without reimplementing anything — the environment and
5
+ capability facts the host already knows.
5
6
 
6
7
  ## What it provides
7
8
 
9
+ Netlist:
10
+
8
11
  | Tool | Scope |
9
12
  |---|---|
10
13
  | `get_project_netlist` | complete logical netlist of the current project |
11
14
  | `get_selection_netlist` | netlist of the currently selected components |
12
15
  | `get_active_page_netlist` | netlist of the active schematic page |
13
16
 
17
+ EDA host discovery:
18
+
19
+ | Tool | Returns |
20
+ |---|---|
21
+ | `get_eda_host_info` | host identity, version, installation paths, `kicad-cli` path |
22
+ | `get_eda_host_capabilities` | capabilities the *current* host can actually provide |
23
+
24
+ Discovery is not implementation: a capability is advertised only when the host
25
+ can already provide it, and never because the EDA application is generally
26
+ known for it.
27
+
14
28
  Each tool returns lossless JSON:
15
29
 
16
30
  ```json
@@ -42,7 +56,12 @@ NOT an error. On failure the tool returns `ok: false` with a semantic
42
56
  - `UNIMPLEMENTED` — the host does not support this scope (e.g. active page on
43
57
  KiCad). Do not retry.
44
58
  - `UNAVAILABLE` — hq-edge host unreachable.
45
- - `INTERNAL` — host-side failure.
59
+ - `DEADLINE_EXCEEDED` — the host did not answer within the request budget.
60
+ - `INTERNAL` — host-side failure or a malformed response.
61
+
62
+ `ok:true` with empty `components`/`nets` means exactly one thing: a genuinely
63
+ empty design. It never means "the parse failed" or "the host is unavailable" —
64
+ those are always `ok:false` with a `kind`.
46
65
 
47
66
  ## Architecture
48
67
 
@@ -58,9 +77,45 @@ EDA Host (hq.ir.schematic.v1.NetListService — KiCad / HQ EDA)
58
77
  native EDA model
59
78
  ```
60
79
 
61
- - No `@hqedge/*` dependency: the base URL arrives as overlay config
62
- (`hqEdgeBaseUrl`, delivered by the hq-edge supervisor) or `HQ_EDGE_BASE_URL`
63
- env fallback.
80
+ - No `@hqedge/*` dependency: the base URL is read from `ctx.hqEdge.baseUrl`
81
+ (provided by the edge-bridge), falling back to overlay config
82
+ (`hqEdgeBaseUrl`) or `HQ_EDGE_BASE_URL`.
64
83
  - No KiCad code, no schematic parsing, no host IPC in this package.
65
84
  - Errors are propagated with semantic kinds — never converted into fake empty
66
85
  netlists.
86
+
87
+ ## EDA host discovery
88
+
89
+ Host info crosses the same boundary as the netlist, using the EDA-independent
90
+ `hq.host.v1` contract — not KiCad-shaped messages:
91
+
92
+ ```text
93
+ get_eda_host_info ─┐
94
+ ├─→ hq-edge /api/v1/host/* ─→ hq.host.v1.EdaHostInfoService ─→ host
95
+ get_eda_host_capabilities ─┘
96
+ ```
97
+
98
+ The host adapts its native facts (version, install path, `kicad-cli` location,
99
+ which editors are open) into that contract. hq-edge performs no host-specific
100
+ translation, so KiCad and HQ EDA implement the same service.
101
+
102
+ **`hq.host.v1` has no availability fields.** A host that answers is available,
103
+ and every executable it lists is one it can run — so unavailability is never a
104
+ flag to check, it is a failed request (`ok:false` with `UNAVAILABLE` /
105
+ `FAILED_PRECONDITION` / `DEADLINE_EXCEEDED`). Because proto3 omits default
106
+ values, a host reporting nothing arrives as `{}`; the client fills the proto3
107
+ defaults so a missing field can never be misread as "unavailable". An empty
108
+ executable `path` means the host did not resolve an absolute location, not that
109
+ the tool is missing.
110
+
111
+ ## Request budget
112
+
113
+ Every request has a single bounded budget (`requestTimeoutMs`, default 30 s),
114
+ enforced here and mirrored by the KiCad UI-dispatch bound. A request that
115
+ exceeds it fails with `DEADLINE_EXCEEDED`; it never returns an empty result.
116
+
117
+ ## Dependency boundary
118
+
119
+ This plugin **requires hq-edge**. It is not a standalone DSH plugin: `hqEdge` is
120
+ a required inject, and `apply()` throws if the bridge did not provide a usable
121
+ endpoint. See `docs/tasks/expose-capability.md` in hq-edge.
package/lib/index.d.mts CHANGED
@@ -16,6 +16,17 @@ interface EdaHostConfig {
16
16
  hqEdgeBaseUrl?: string;
17
17
  /** Path prefix on the host; default "/api/v1/netlist". */
18
18
  netlistPathPrefix?: string;
19
+ /** Path prefix for host discovery; default "/api/v1/host". */
20
+ hostPathPrefix?: string;
21
+ /**
22
+ * End-to-end budget for one EDA-host request, in milliseconds.
23
+ *
24
+ * This is the SINGLE request budget for the whole chain — the plugin does
25
+ * not define separate per-layer timeouts. It is enforced here (outermost)
26
+ * and mirrored by the KiCad UI-dispatch bound, so no agent request can wait
27
+ * indefinitely. See docs/tasks/expose-capability.md §6.
28
+ */
29
+ requestTimeoutMs?: number;
19
30
  }
20
31
  //#endregion
21
32
  //#region src/types.d.ts
@@ -70,21 +81,111 @@ type NetlistErrorKind =
70
81
  /** Host-side runtime failure. */
71
82
  'INTERNAL' |
72
83
  /** Host unavailable (connection refused). */
73
- 'UNAVAILABLE';
84
+ 'UNAVAILABLE' |
85
+ /** The host did not answer within the request budget. */
86
+ 'DEADLINE_EXCEEDED';
74
87
  declare class NetlistError extends Error {
75
88
  readonly kind: NetlistErrorKind;
76
89
  constructor(kind: NetlistErrorKind, message: string);
77
90
  }
91
+ /**
92
+ * `hq.host.v1` carries **no availability flags**.
93
+ *
94
+ * A host that answers `GetEdaHostInfo` is, by definition, available, and every
95
+ * executable it lists is one it can actually run. Unavailability is therefore
96
+ * never a field to check — it is a failed request, surfaced as `ok:false` with
97
+ * `error.kind` `UNAVAILABLE` / `FAILED_PRECONDITION` / `DEADLINE_EXCEEDED`.
98
+ *
99
+ * Consequence for consumers: never infer "unavailable" from a missing or empty
100
+ * value. Absence of `identity`/`installation` just means proto3 omitted
101
+ * defaults (see `parseEdaHostInfo`), and empty `path` only means the host could
102
+ * not resolve an absolute location — the tool is still runnable by name.
103
+ */
104
+ /**
105
+ * Which EDA application sits behind the semantic host boundary.
106
+ *
107
+ * Deliberately EDA-independent: a new host adds a value here rather than
108
+ * introducing host-specific messages or tools.
109
+ */
110
+ type EdaHostType = 'EDA_HOST_TYPE_UNSPECIFIED' | 'EDA_HOST_TYPE_KICAD' | 'EDA_HOST_TYPE_HQ_EDA';
111
+ /**
112
+ * A capability an EDA host may provide.
113
+ *
114
+ * A capability is advertised only when the host can actually provide it —
115
+ * discovering a capability is not the same as implementing it. Unknown values
116
+ * MUST be treated as "not supported" so a newer host cannot confuse an older
117
+ * plugin.
118
+ */
119
+ type EdaHostCapability = 'EDA_HOST_CAPABILITY_UNSPECIFIED' | 'EDA_HOST_CAPABILITY_SCHEMATIC' | 'EDA_HOST_CAPABILITY_PCB' | 'EDA_HOST_CAPABILITY_NETLIST' | 'EDA_HOST_CAPABILITY_NETLIST_SELECTION' | 'EDA_HOST_CAPABILITY_NETLIST_ACTIVE_PAGE' | 'EDA_HOST_CAPABILITY_ERC' | 'EDA_HOST_CAPABILITY_DRC' | 'EDA_HOST_CAPABILITY_BOM' | 'EDA_HOST_CAPABILITY_PLACEMENT';
120
+ /** A host-provided command line tool. */
121
+ interface EdaHostExecutable {
122
+ /** Stable tool name, e.g. "kicad-cli". */
123
+ name: string;
124
+ /**
125
+ * Absolute path when the host could resolve one.
126
+ *
127
+ * Empty means "resolvable by name only" (e.g. found on `PATH` but the host
128
+ * did not report a location) — never "not installed" or "unusable".
129
+ */
130
+ path: string;
131
+ }
132
+ /** Where the host application lives on disk. */
133
+ interface EdaHostInstallation {
134
+ applicationPath: string;
135
+ /**
136
+ * Executables the host can run. Being listed here IS the availability
137
+ * signal: there is no per-executable availability flag.
138
+ */
139
+ executables: EdaHostExecutable[];
140
+ }
141
+ /** Which EDA host is connected. */
142
+ interface EdaHostIdentity {
143
+ hostType: EdaHostType;
144
+ hostName: string;
145
+ version: string;
146
+ }
147
+ /**
148
+ * EDA-independent description of the connected host.
149
+ *
150
+ * Answering this message is the host's way of saying it is available — there is
151
+ * no `available` field to inspect.
152
+ */
153
+ interface EdaHostInfo {
154
+ identity: EdaHostIdentity;
155
+ installation: EdaHostInstallation;
156
+ }
78
157
  //#endregion
79
158
  //#region src/client.d.ts
159
+ /** Per-call options. `signal` lets DSH cancel a request (see §6 of the task). */
160
+ interface EdaHostRequestOptions {
161
+ signal?: AbortSignal;
162
+ }
80
163
  interface EdaHostClient {
81
164
  /** Netlist of the currently selected components. */
82
- getSelectionNetlist(): Promise<SchematicNetlist>;
165
+ getSelectionNetlist(options?: EdaHostRequestOptions): Promise<SchematicNetlist>;
83
166
  /** Complete logical netlist for the current project. */
84
- getProjectNetlist(): Promise<SchematicNetlist>;
167
+ getProjectNetlist(options?: EdaHostRequestOptions): Promise<SchematicNetlist>;
85
168
  /** Netlist of the current active schematic page. */
86
- getActivePageNetlist(): Promise<SchematicNetlist>;
169
+ getActivePageNetlist(options?: EdaHostRequestOptions): Promise<SchematicNetlist>;
170
+ /**
171
+ * EDA-independent identity / installation of the host. Presence of a result
172
+ * is the availability signal — `hq.host.v1` has no availability field.
173
+ */
174
+ getEdaHostInfo(options?: EdaHostRequestOptions): Promise<EdaHostInfo>;
175
+ /** Capabilities the host currently provides. */
176
+ getEdaHostCapabilities(options?: EdaHostRequestOptions): Promise<EdaHostCapability[]>;
87
177
  }
178
+ /**
179
+ * Extract the semantic `SchematicNetlist` from an hq-edge netlist body.
180
+ *
181
+ * hq-edge now emits a single-level body: `{ netlist: { components, nets } }`.
182
+ * Older hq-edge builds serialized the protobuf envelope
183
+ * (`GetNetListResponse.oneof result`), which produced a second `netlist` level
184
+ * and made every populated design look empty. That legacy shape is unwrapped
185
+ * EXPLICITLY — not silently — and anything else is a hard error, because
186
+ * "malformed" must never masquerade as "empty design".
187
+ */
188
+ declare function parseNetlistBody(body: unknown): SchematicNetlist;
88
189
  //#endregion
89
190
  //#region src/index.d.ts
90
191
  /** Plugin id — matches package.json. */
@@ -147,4 +248,4 @@ declare module '@deepseek-ai/cordis' {
147
248
  */
148
249
  declare function apply(ctx: Context, config?: Partial<EdaHostConfig>): () => void;
149
250
  //#endregion
150
- export { type EdaHostClient, type EdaHostConfig, type ElectricalNet, type ElectricalType, NetlistError, type NetlistErrorKind, type PinDefinition, type PinReference, type SchematicComponent, type SchematicNetlist, apply, inject, name };
251
+ export { type EdaHostCapability, type EdaHostClient, type EdaHostConfig, type EdaHostExecutable, type EdaHostIdentity, type EdaHostInfo, type EdaHostInstallation, type EdaHostRequestOptions, type EdaHostType, type ElectricalNet, type ElectricalType, NetlistError, type NetlistErrorKind, type PinDefinition, type PinReference, type SchematicComponent, type SchematicNetlist, apply, inject, name, parseNetlistBody };
package/lib/index.mjs CHANGED
@@ -1,14 +1,22 @@
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 hostPrefix = config?.hostPathPrefix ?? env.HQ_EDGE_HOST_PATH ?? "/api/v1/host";
13
+ const timeoutRaw = config?.requestTimeoutMs ?? env.HQ_EDGE_REQUEST_TIMEOUT_MS;
14
+ const timeout = Number.parseInt(String(timeoutRaw ?? ""), 10);
9
15
  return {
10
- hqEdgeBaseUrl: config?.hqEdgeBaseUrl ?? env.HQ_EDGE_BASE_URL ?? "",
11
- netlistPathPrefix: config?.netlistPathPrefix ?? env.HQ_EDGE_NETLIST_PATH ?? "/api/v1/netlist"
16
+ hqEdgeBaseUrl: baseUrl,
17
+ netlistPathPrefix: pathPrefix,
18
+ hostPathPrefix: hostPrefix,
19
+ requestTimeoutMs: Number.isFinite(timeout) && timeout > 0 ? timeout : DEFAULT_REQUEST_TIMEOUT_MS
12
20
  };
13
21
  }
14
22
  /** True when a host base URL is available (host mode). */
@@ -19,6 +27,14 @@ function hasHost(config) {
19
27
  function netlistUrlOf(config, scope) {
20
28
  return `${(config.hqEdgeBaseUrl ?? "").replace(/\/+$/, "")}/${(config.netlistPathPrefix ?? "/api/v1/netlist").replace(/^\/+|\/+$/g, "")}${SCOPE_ROUTE[scope]}`;
21
29
  }
30
+ const HOST_ROUTE = {
31
+ info: "/info",
32
+ capabilities: "/capabilities"
33
+ };
34
+ /** Build the absolute URL for one host discovery route. */
35
+ function hostUrlOf(config, route) {
36
+ return `${(config.hqEdgeBaseUrl ?? "").replace(/\/+$/, "")}/${(config.hostPathPrefix ?? "/api/v1/host").replace(/^\/+|\/+$/g, "")}${HOST_ROUTE[route]}`;
37
+ }
22
38
  //#endregion
23
39
  //#region src/types.ts
24
40
  var NetlistError = class extends Error {
@@ -32,38 +48,128 @@ var NetlistError = class extends Error {
32
48
  //#endregion
33
49
  //#region src/client.ts
34
50
  /**
35
- * Netlist transport for `@huaqiu/dsh-eda-host`.
51
+ * Transport for `@huaqiu/dsh-eda-host`.
36
52
  *
37
53
  * 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.
54
+ * module fetches the semantic netlist and the EDA-independent host information
55
+ * from hq-edge, maps HTTP statuses back to the semantic gRPC error categories,
56
+ * and enforces the single end-to-end request budget. It never parses schematic
57
+ * files and never touches KiCad.
41
58
  *
42
59
  * @module
43
60
  */
44
- /** HTTP status → semantic error kind (see routes/netlist.ts on hq-edge). */
61
+ /** HTTP status → semantic error kind (see routes/edaHostStatus.ts on hq-edge). */
45
62
  function statusToKind(status) {
46
63
  if (status === 412) return "FAILED_PRECONDITION";
47
64
  if (status === 501) return "UNIMPLEMENTED";
48
65
  if (status === 503) return "UNAVAILABLE";
66
+ if (status === 504) return "DEADLINE_EXCEEDED";
49
67
  return "INTERNAL";
50
68
  }
69
+ /** True when a thrown fetch error is an abort/timeout rather than a transport error. */
70
+ function isAbortError(err) {
71
+ const name = err?.name;
72
+ return name === "AbortError" || name === "TimeoutError";
73
+ }
74
+ /**
75
+ * Combine an optional caller signal with the plugin's own budget.
76
+ *
77
+ * `AbortSignal.any` is not available on every Node version DSH may run on, so
78
+ * fall back to whichever signal exists — the budget is always present, which is
79
+ * what guarantees no request can wait indefinitely.
80
+ */
81
+ function resolveSignal(deadlineMs, caller) {
82
+ const signals = [caller, deadlineMs > 0 ? AbortSignal.timeout(deadlineMs) : void 0].filter((s) => Boolean(s));
83
+ if (signals.length === 0) return void 0;
84
+ if (signals.length === 1) return signals[0];
85
+ const anyFn = AbortSignal.any;
86
+ return typeof anyFn === "function" ? anyFn.call(AbortSignal, signals) : signals[0];
87
+ }
88
+ /**
89
+ * Extract the semantic `SchematicNetlist` from an hq-edge netlist body.
90
+ *
91
+ * hq-edge now emits a single-level body: `{ netlist: { components, nets } }`.
92
+ * Older hq-edge builds serialized the protobuf envelope
93
+ * (`GetNetListResponse.oneof result`), which produced a second `netlist` level
94
+ * and made every populated design look empty. That legacy shape is unwrapped
95
+ * EXPLICITLY — not silently — and anything else is a hard error, because
96
+ * "malformed" must never masquerade as "empty design".
97
+ */
98
+ function parseNetlistBody(body) {
99
+ if (!body || typeof body !== "object" || Array.isArray(body)) throw new NetlistError("INTERNAL", "eda-host: malformed netlist response from hq-edge");
100
+ let candidate = body.netlist;
101
+ if (candidate && typeof candidate === "object" && !Array.isArray(candidate) && candidate.components === void 0 && typeof candidate.netlist === "object") candidate = candidate.netlist;
102
+ if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) throw new NetlistError("INTERNAL", "eda-host: malformed netlist response from hq-edge");
103
+ const { components, nets } = candidate;
104
+ if (components !== void 0 && !Array.isArray(components)) throw new NetlistError("INTERNAL", "eda-host: netlist.components is not an array");
105
+ if (nets !== void 0 && !Array.isArray(nets)) throw new NetlistError("INTERNAL", "eda-host: netlist.nets is not an array");
106
+ return {
107
+ components: components ?? [],
108
+ nets: nets ?? []
109
+ };
110
+ }
111
+ function str(value) {
112
+ return typeof value === "string" ? value : "";
113
+ }
114
+ /**
115
+ * Extract a complete `EdaHostInfo` from an hq-edge host-info body.
116
+ *
117
+ * `hq.host.v1` has no availability flags, so the only way to distinguish "the
118
+ * host is here" from "the host is not" is whether this call succeeded at all.
119
+ * That makes the *shape* the contract: proto3 JSON omits default-valued fields,
120
+ * so a host that legitimately reports nothing arrives as `{}`. Rather than let
121
+ * a half-empty object reach the agent — where a missing field could be misread
122
+ * as "not available" — absent values are filled with their proto3 defaults.
123
+ *
124
+ * Values that are present are never reinterpreted; unknown `hostType` strings
125
+ * pass through so a newer host cannot be silently downgraded.
126
+ */
127
+ function parseEdaHostInfo(value) {
128
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new NetlistError("INTERNAL", "eda-host: malformed host info response from hq-edge");
129
+ const raw = value;
130
+ const executables = Array.isArray(raw.installation?.executables) ? (raw.installation?.executables).filter((e) => Boolean(e) && typeof e === "object").map((e) => ({
131
+ name: str(e.name),
132
+ path: str(e.path)
133
+ })) : [];
134
+ return {
135
+ identity: {
136
+ hostType: typeof raw.identity?.hostType === "string" ? raw.identity.hostType : "EDA_HOST_TYPE_UNSPECIFIED",
137
+ hostName: str(raw.identity?.hostName),
138
+ version: str(raw.identity?.version)
139
+ },
140
+ installation: {
141
+ applicationPath: str(raw.installation?.applicationPath),
142
+ executables
143
+ }
144
+ };
145
+ }
51
146
  function createEdaHostClient(config, deps = {}) {
52
147
  const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
53
- async function fetchScope(scope) {
148
+ /**
149
+ * Resolve the host endpoint per request. A resolver (ctx.hqEdge) wins over
150
+ * the static config/env value; if neither yields a URL we degrade to the
151
+ * same clear FAILED_PRECONDITION the standalone install path uses.
152
+ */
153
+ function resolveConfig() {
54
154
  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({
155
+ 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.");
156
+ return {
57
157
  ...config,
58
158
  hqEdgeBaseUrl: baseUrl
59
- }, scope);
159
+ };
160
+ }
161
+ /** Perform one GET and return the parsed JSON body, mapping failures. */
162
+ async function getJson(url, options, what) {
163
+ const signal = resolveSignal(config.requestTimeoutMs ?? 3e4, options?.signal);
60
164
  let response;
61
165
  try {
62
166
  response = await fetchImpl(url, {
63
167
  method: "GET",
64
- headers: { Accept: "application/json" }
168
+ headers: { Accept: "application/json" },
169
+ ...signal ? { signal } : {}
65
170
  });
66
171
  } catch (err) {
172
+ if (isAbortError(err)) throw new NetlistError("DEADLINE_EXCEEDED", `eda-host: ${what} request exceeded its time budget at ${url}`);
67
173
  throw new NetlistError("UNAVAILABLE", `eda-host: cannot reach hq-edge at ${url}: ${String(err?.message ?? err)}`);
68
174
  }
69
175
  if (!response.ok) {
@@ -72,19 +178,27 @@ function createEdaHostClient(config, deps = {}) {
72
178
  const body = await response.json();
73
179
  if (typeof body.detail === "string") detail = body.detail;
74
180
  } catch {}
75
- throw new NetlistError(statusToKind(response.status), `eda-host: netlist request failed (${response.status}${detail ? `: ${detail}` : ""})`);
181
+ throw new NetlistError(statusToKind(response.status), `eda-host: ${what} request failed (${response.status}${detail ? `: ${detail}` : ""})`);
76
182
  }
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;
183
+ return response.json();
184
+ }
185
+ async function fetchScope(scope, options) {
186
+ return parseNetlistBody(await getJson(netlistUrlOf(resolveConfig(), scope), options, "netlist"));
83
187
  }
84
188
  return {
85
- getSelectionNetlist: () => fetchScope("selection"),
86
- getProjectNetlist: () => fetchScope("project"),
87
- getActivePageNetlist: () => fetchScope("active-page")
189
+ getSelectionNetlist: (options) => fetchScope("selection", options),
190
+ getProjectNetlist: (options) => fetchScope("project", options),
191
+ getActivePageNetlist: (options) => fetchScope("active-page", options),
192
+ getEdaHostInfo: async (options) => {
193
+ const body = await getJson(hostUrlOf(resolveConfig(), "info"), options, "host info");
194
+ if (!body || typeof body.info !== "object" || body.info === null) throw new NetlistError("INTERNAL", "eda-host: malformed host info response from hq-edge");
195
+ return parseEdaHostInfo(body.info);
196
+ },
197
+ getEdaHostCapabilities: async (options) => {
198
+ const body = await getJson(hostUrlOf(resolveConfig(), "capabilities"), options, "host capabilities");
199
+ if (!body || !Array.isArray(body.capabilities)) throw new NetlistError("INTERNAL", "eda-host: malformed host capabilities response from hq-edge");
200
+ return body.capabilities.filter((c) => typeof c === "string");
201
+ }
88
202
  };
89
203
  }
90
204
  //#endregion
@@ -92,17 +206,23 @@ function createEdaHostClient(config, deps = {}) {
92
206
  /**
93
207
  * Agent tools for `@huaqiu/dsh-eda-host`.
94
208
  *
95
- * Three semantic operations, one per netlist scope:
209
+ * Three semantic netlist operations, one per scope:
96
210
  *
97
211
  * get_project_netlist complete logical netlist of the current project
98
212
  * get_selection_netlist netlist of the currently selected components
99
213
  * get_active_page_netlist netlist of the active schematic page
100
214
  *
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`.
215
+ * Two EDA host discovery operations:
216
+ *
217
+ * get_eda_host_info which host, which version, where it is installed
218
+ * get_eda_host_capabilities what the current host can actually do
219
+ *
220
+ * The netlist tools are pure pass-throughs: they call the hq-edge netlist
221
+ * router and return the semantic `SchematicNetlist` as lossless JSON. Errors
222
+ * are propagated with a semantic `kind` (FAILED_PRECONDITION / UNIMPLEMENTED /
223
+ * INTERNAL / UNAVAILABLE / DEADLINE_EXCEEDED) — never converted into a fake
224
+ * empty netlist. A valid-but-empty netlist is `ok: true` with empty
225
+ * `components`/`nets`.
106
226
  *
107
227
  * @module
108
228
  */
@@ -116,12 +236,25 @@ function renderJson(_args, value) {
116
236
  text: JSON.stringify(value)
117
237
  }];
118
238
  }
119
- async function runScope(env, scope) {
239
+ /**
240
+ * Every failure kind, and what the agent should do about it.
241
+ *
242
+ * Shared by all tools so the prompt contract cannot drift between them.
243
+ */
244
+ 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.";
245
+ function failureOf(err) {
246
+ return {
247
+ kind: err instanceof NetlistError ? err.kind : "INTERNAL",
248
+ message: String(err?.message ?? err)
249
+ };
250
+ }
251
+ async function runScope(env, scope, exec) {
252
+ const options = exec?.signal ? { signal: exec.signal } : {};
120
253
  try {
121
254
  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();
255
+ if (scope === "project") netlist = await env.client.getProjectNetlist(options);
256
+ else if (scope === "selection") netlist = await env.client.getSelectionNetlist(options);
257
+ else netlist = await env.client.getActivePageNetlist(options);
125
258
  return {
126
259
  ok: true,
127
260
  scope,
@@ -131,15 +264,12 @@ async function runScope(env, scope) {
131
264
  return {
132
265
  ok: false,
133
266
  scope,
134
- error: {
135
- kind: err instanceof NetlistError ? err.kind : "INTERNAL",
136
- message: String(err?.message ?? err)
137
- }
267
+ error: failureOf(err)
138
268
  };
139
269
  }
140
270
  }
141
271
  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.";
272
+ 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
273
  }
144
274
  function createNetListTools(env) {
145
275
  const mkTool = (scope, name, desc) => defineTool({
@@ -150,16 +280,70 @@ function createNetListTools(env) {
150
280
  schema: { type: "json" },
151
281
  render: renderJson
152
282
  },
153
- async execute(_args, _exec) {
154
- return asJson(await runScope(env, scope));
283
+ async execute(_args, exec) {
284
+ return asJson(await runScope(env, scope, exec));
155
285
  }
156
286
  });
157
287
  return [
158
288
  mkTool("project", "get_project_netlist", scopeDescription("project", "Returns the complete logical netlist for the current project (all sheets, all nets).")),
159
289
  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\"."))
290
+ 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."))
161
291
  ];
162
292
  }
293
+ /**
294
+ * EDA host discovery tools.
295
+ *
296
+ * These expose what the EDA host ALREADY knows and can ALREADY do — they
297
+ * implement no EDA functionality themselves. A capability being advertised is
298
+ * a claim that the host can provide it, nothing more.
299
+ */
300
+ function createEdaHostTools(env) {
301
+ return [defineTool({
302
+ name: "get_eda_host_info",
303
+ 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,
304
+ parameters: {},
305
+ output: {
306
+ schema: { type: "json" },
307
+ render: renderJson
308
+ },
309
+ async execute(_args, exec) {
310
+ try {
311
+ const options = exec?.signal ? { signal: exec.signal } : {};
312
+ return asJson({
313
+ ok: true,
314
+ info: await env.client.getEdaHostInfo(options)
315
+ });
316
+ } catch (err) {
317
+ return asJson({
318
+ ok: false,
319
+ error: failureOf(err)
320
+ });
321
+ }
322
+ }
323
+ }), defineTool({
324
+ name: "get_eda_host_capabilities",
325
+ 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,
326
+ parameters: {},
327
+ output: {
328
+ schema: { type: "json" },
329
+ render: renderJson
330
+ },
331
+ async execute(_args, exec) {
332
+ try {
333
+ const options = exec?.signal ? { signal: exec.signal } : {};
334
+ return asJson({
335
+ ok: true,
336
+ capabilities: await env.client.getEdaHostCapabilities(options)
337
+ });
338
+ } catch (err) {
339
+ return asJson({
340
+ ok: false,
341
+ error: failureOf(err)
342
+ });
343
+ }
344
+ }
345
+ })];
346
+ }
163
347
  //#endregion
164
348
  //#region src/index.ts
165
349
  /** Plugin id — matches package.json. */
@@ -182,6 +366,7 @@ const name = "@huaqiu/dsh-eda-host";
182
366
  */
183
367
  const inject = ["hqEdge", "tools"];
184
368
  const log = getLogger("dsh-eda-host");
369
+ log.info("dsh-eda-host: module loaded (waiting for the hqEdge + tools services)");
185
370
  /**
186
371
  * Host plugin body — provide `edaHost` and register the three netlist tools.
187
372
  *
@@ -201,22 +386,26 @@ const log = getLogger("dsh-eda-host");
201
386
  function apply(ctx, config = {}) {
202
387
  if (!ctx.tools || typeof ctx.tools.register !== "function") throw new Error("@huaqiu/dsh-eda-host requires the DSH `tools` service (ctx.tools.register).");
203
388
  const resolved = resolveEdaHostConfig(config);
389
+ const hq = ctx.hqEdge;
390
+ 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
391
  const getHqEdgeBaseUrl = () => {
205
- const hq = ctx.hqEdge;
206
- return hq?.baseUrl && hq.baseUrl.trim().length > 0 ? hq.baseUrl : void 0;
392
+ const current = ctx.hqEdge;
393
+ return current?.baseUrl && current.baseUrl.trim().length > 0 ? current.baseUrl : void 0;
207
394
  };
208
395
  log.info("applying dsh-eda-host node half", {
209
396
  hasConfigHost: hasHost(resolved),
210
397
  hqEdgeBaseUrlFromConfig: resolved.hqEdgeBaseUrl ?? null,
211
- netlistPathPrefix: resolved.netlistPathPrefix
398
+ netlistPathPrefix: resolved.netlistPathPrefix,
399
+ hostPathPrefix: resolved.hostPathPrefix,
400
+ requestTimeoutMs: resolved.requestTimeoutMs
212
401
  });
213
402
  const client = createEdaHostClient(resolved, { baseUrlResolver: getHqEdgeBaseUrl });
214
403
  ctx.effect(() => ctx.provide("edaHost", client));
215
- const tools = createNetListTools({ client });
404
+ const tools = [...createNetListTools({ client }), ...createEdaHostTools({ client })];
216
405
  const disposers = [];
217
406
  for (const tool of tools) disposers.push(ctx.tools.register(tool));
218
407
  log.info("dsh-eda-host node half ready", {
219
- tools: 3,
408
+ tools: tools.length,
220
409
  configHostMode: hasHost(resolved)
221
410
  });
222
411
  return () => {
@@ -226,4 +415,4 @@ function apply(ctx, config = {}) {
226
415
  };
227
416
  }
228
417
  //#endregion
229
- export { NetlistError, apply, inject, name };
418
+ 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.22",
3
+ "version": "0.3.25",
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.22"
25
+ "@huaqiu/dsh-plugin-log": "0.3.25"
26
26
  },
27
27
  "files": [
28
28
  "lib",