@huaqiu/dsh-eda-host 0.3.20

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 深圳华秋智联股份有限公司
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # @huaqiu/dsh-eda-host
2
+
3
+ DSH plugin exposing the current EDA host's schematic netlist to agents through
4
+ hq-edge.
5
+
6
+ ## What it provides
7
+
8
+ | Tool | Scope |
9
+ |---|---|
10
+ | `get_project_netlist` | complete logical netlist of the current project |
11
+ | `get_selection_netlist` | netlist of the currently selected components |
12
+ | `get_active_page_netlist` | netlist of the active schematic page |
13
+
14
+ Each tool returns lossless JSON:
15
+
16
+ ```json
17
+ {
18
+ "ok": true,
19
+ "scope": "project",
20
+ "netlist": {
21
+ "components": [
22
+ { "referenceDesignators": ["R1"], "value": "10k", "footprint": "R_0603",
23
+ "manufacturerPartNumber": "", "description": "", "pins": [
24
+ { "pinNumber": "1", "pinName": "1", "electricalType": "ELECTRICAL_TYPE_PASSIVE" }
25
+ ] }
26
+ ],
27
+ "nets": [
28
+ { "name": "GND", "pinReferences": [
29
+ { "referenceDesignator": "R1", "pinNumber": "2" }
30
+ ] }
31
+ ]
32
+ }
33
+ }
34
+ ```
35
+
36
+ A valid-but-empty design is `ok: true` with empty `components`/`nets` — it is
37
+ NOT an error. On failure the tool returns `ok: false` with a semantic
38
+ `error.kind`:
39
+
40
+ - `FAILED_PRECONDITION` — no EDA host / no live editor (ask the user to open
41
+ the design first).
42
+ - `UNIMPLEMENTED` — the host does not support this scope (e.g. active page on
43
+ KiCad). Do not retry.
44
+ - `UNAVAILABLE` — hq-edge host unreachable.
45
+ - `INTERNAL` — host-side failure.
46
+
47
+ ## Architecture
48
+
49
+ ```
50
+ DSH
51
+
52
+ dsh-eda-host (this plugin: DSH integration only, self-contained)
53
+ ↓ hq-edge HTTP
54
+ hq-edge (semantic protocol + orchestration; /api/v1/netlist/*)
55
+ ↓ gRPC
56
+ EDA Host (hq.ir.schematic.v1.NetListService — KiCad / HQ EDA)
57
+
58
+ native EDA model
59
+ ```
60
+
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.
64
+ - No KiCad code, no schematic parsing, no host IPC in this package.
65
+ - Errors are propagated with semantic kinds — never converted into fake empty
66
+ netlists.
@@ -0,0 +1,8 @@
1
+ # DSH bundle patch: inserts the Huaqiu EDA host netlist tool plugin.
2
+ # The entry inject must match the node-half module contract (`src/index.ts` —
3
+ # export const inject). The tool's apply() reads the `tools` service; the
4
+ # hq-edge supervisor delivers the base URL as overlay config.
5
+ - insert:
6
+ - id: huaqiu-dsh-eda-host
7
+ name: '@huaqiu/dsh-eda-host'
8
+ inject: ['tools']
@@ -0,0 +1,112 @@
1
+ import { Context } from "@deepseek-ai/cordis";
2
+ //#region src/config.d.ts
3
+ /**
4
+ * Runtime config resolution for `@huaqiu/dsh-eda-host`.
5
+ *
6
+ * The plugin never opens a direct connection to the EDA host — it only talks
7
+ * to `hq-edge`, which owns the host session. The base URL is delivered by the
8
+ * hq-edge supervisor as overlay config (`hqEdgeBaseUrl`), with the
9
+ * `HQ_EDGE_BASE_URL` env as fallback for non-supervisor installs. This mirrors
10
+ * the `@huaqiu/dsh-auth` host-config convention.
11
+ *
12
+ * @module
13
+ */
14
+ interface EdaHostConfig {
15
+ /** HQ Edge base URL, e.g. "http://localhost:18080". Absent → no host. */
16
+ hqEdgeBaseUrl?: string;
17
+ /** Path prefix on the host; default "/api/v1/netlist". */
18
+ netlistPathPrefix?: string;
19
+ }
20
+ //#endregion
21
+ //#region src/types.d.ts
22
+ /**
23
+ * Semantic netlist types for `@huaqiu/dsh-eda-host`.
24
+ *
25
+ * Self-contained structural mirror of `hq.ir.schematic.v1` (the hq-edge-owned
26
+ * semantic protobuf contract). The plugin intentionally does NOT depend on
27
+ * `@hqedge/*` — this file is the only place the wire shapes are named, so
28
+ * keeping them aligned with the proto is a one-file concern.
29
+ *
30
+ * @module
31
+ */
32
+ /** Electrical type of a pin — mirrors `hq.ir.logical.v1.ElectricalType`. */
33
+ type ElectricalType = 'ELECTRICAL_TYPE_UNSPECIFIED' | 'ELECTRICAL_TYPE_INPUT' | 'ELECTRICAL_TYPE_OUTPUT' | 'ELECTRICAL_TYPE_BIDIRECTIONAL' | 'ELECTRICAL_TYPE_TRISTATE' | 'ELECTRICAL_TYPE_PASSIVE' | 'ELECTRICAL_TYPE_POWER_IN' | 'ELECTRICAL_TYPE_POWER_OUT' | 'ELECTRICAL_TYPE_OPEN_COLLECTOR' | 'ELECTRICAL_TYPE_OPEN_EMITTER' | 'ELECTRICAL_TYPE_NO_CONNECT';
34
+ interface PinDefinition {
35
+ pinNumber: string;
36
+ pinName: string;
37
+ electricalType: ElectricalType;
38
+ }
39
+ interface SchematicComponent {
40
+ referenceDesignators: string[];
41
+ value: string;
42
+ manufacturerPartNumber: string;
43
+ footprint: string;
44
+ description: string;
45
+ pins: PinDefinition[];
46
+ }
47
+ interface PinReference {
48
+ referenceDesignator: string;
49
+ pinNumber: string;
50
+ }
51
+ interface ElectricalNet {
52
+ name: string;
53
+ portName?: string;
54
+ pinReferences: PinReference[];
55
+ }
56
+ interface SchematicNetlist {
57
+ components: SchematicComponent[];
58
+ nets: ElectricalNet[];
59
+ }
60
+ /**
61
+ * Semantic error categories for netlist retrieval. Mirrors the gRPC status
62
+ * contract of `hq.ir.schematic.v1.NetListService` so an agent can distinguish
63
+ * "valid but empty" from "cannot answer at all".
64
+ */
65
+ type NetlistErrorKind =
66
+ /** No EDA host is reachable (hq-edge not configured / host down). */
67
+ 'FAILED_PRECONDITION' |
68
+ /** The host does not implement this scope (e.g. active page on KiCad). */
69
+ 'UNIMPLEMENTED' |
70
+ /** Host-side runtime failure. */
71
+ 'INTERNAL' |
72
+ /** Host unavailable (connection refused). */
73
+ 'UNAVAILABLE';
74
+ declare class NetlistError extends Error {
75
+ readonly kind: NetlistErrorKind;
76
+ constructor(kind: NetlistErrorKind, message: string);
77
+ }
78
+ //#endregion
79
+ //#region src/client.d.ts
80
+ interface EdaHostClient {
81
+ /** Netlist of the currently selected components. */
82
+ getSelectionNetlist(): Promise<SchematicNetlist>;
83
+ /** Complete logical netlist for the current project. */
84
+ getProjectNetlist(): Promise<SchematicNetlist>;
85
+ /** Netlist of the current active schematic page. */
86
+ getActivePageNetlist(): Promise<SchematicNetlist>;
87
+ }
88
+ //#endregion
89
+ //#region src/index.d.ts
90
+ /** Plugin id — matches package.json. */
91
+ declare const name = "@huaqiu/dsh-eda-host";
92
+ /** Cordis services this half depends on. */
93
+ declare const inject: readonly ["tools"];
94
+ declare module '@deepseek-ai/cordis' {
95
+ interface Context {
96
+ edaHost: EdaHostClient;
97
+ }
98
+ }
99
+ /**
100
+ * Host plugin body — provide `edaHost` and register the three netlist tools.
101
+ *
102
+ * When no host base URL is configured, tools return ok:false with
103
+ * error.kind "FAILED_PRECONDITION" instead of throwing at load time: the
104
+ * plugin can be installed in standalone DSH where hq-edge is absent, and the
105
+ * tools degrade to a clear semantic message.
106
+ *
107
+ * @param ctx - real cordis context (node side).
108
+ * @returns disposer — unregisters the tools on plugin dispose.
109
+ */
110
+ declare function apply(ctx: Context, config?: Partial<EdaHostConfig>): () => void;
111
+ //#endregion
112
+ export { type EdaHostClient, type EdaHostConfig, type ElectricalNet, type ElectricalType, NetlistError, type NetlistErrorKind, type PinDefinition, type PinReference, type SchematicComponent, type SchematicNetlist, apply, inject, name };
package/lib/index.mjs ADDED
@@ -0,0 +1,212 @@
1
+ import { getLogger } from "@huaqiu/dsh-plugin-log";
2
+ import { defineTool } from "@deepseek-ai/dsh-tools";
3
+ const SCOPE_ROUTE = {
4
+ project: "/project",
5
+ selection: "/selection",
6
+ "active-page": "/active-page"
7
+ };
8
+ function resolveEdaHostConfig(config, env = process.env) {
9
+ return {
10
+ hqEdgeBaseUrl: config?.hqEdgeBaseUrl ?? env.HQ_EDGE_BASE_URL ?? "",
11
+ netlistPathPrefix: config?.netlistPathPrefix ?? env.HQ_EDGE_NETLIST_PATH ?? "/api/v1/netlist"
12
+ };
13
+ }
14
+ /** True when a host base URL is available (host mode). */
15
+ function hasHost(config) {
16
+ return typeof config.hqEdgeBaseUrl === "string" && config.hqEdgeBaseUrl.trim().length > 0;
17
+ }
18
+ /** Build the absolute URL for one netlist scope. */
19
+ function netlistUrlOf(config, scope) {
20
+ return `${(config.hqEdgeBaseUrl ?? "").replace(/\/+$/, "")}/${(config.netlistPathPrefix ?? "/api/v1/netlist").replace(/^\/+|\/+$/g, "")}${SCOPE_ROUTE[scope]}`;
21
+ }
22
+ //#endregion
23
+ //#region src/types.ts
24
+ var NetlistError = class extends Error {
25
+ kind;
26
+ constructor(kind, message) {
27
+ super(message);
28
+ this.name = "NetlistError";
29
+ this.kind = kind;
30
+ }
31
+ };
32
+ //#endregion
33
+ //#region src/client.ts
34
+ /**
35
+ * Netlist transport for `@huaqiu/dsh-eda-host`.
36
+ *
37
+ * 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.
41
+ *
42
+ * @module
43
+ */
44
+ /** HTTP status → semantic error kind (see routes/netlist.ts on hq-edge). */
45
+ function statusToKind(status) {
46
+ if (status === 412) return "FAILED_PRECONDITION";
47
+ if (status === 501) return "UNIMPLEMENTED";
48
+ if (status === 503) return "UNAVAILABLE";
49
+ return "INTERNAL";
50
+ }
51
+ function createEdaHostClient(config, deps = {}) {
52
+ const fetchImpl = deps.fetchImpl ?? globalThis.fetch;
53
+ async function fetchScope(scope) {
54
+ const url = netlistUrlOf(config, scope);
55
+ let response;
56
+ try {
57
+ response = await fetchImpl(url, {
58
+ method: "GET",
59
+ headers: { Accept: "application/json" }
60
+ });
61
+ } catch (err) {
62
+ throw new NetlistError("UNAVAILABLE", `eda-host: cannot reach hq-edge at ${url}: ${String(err?.message ?? err)}`);
63
+ }
64
+ if (!response.ok) {
65
+ let detail = "";
66
+ try {
67
+ const body = await response.json();
68
+ if (typeof body.detail === "string") detail = body.detail;
69
+ } catch {}
70
+ throw new NetlistError(statusToKind(response.status), `eda-host: netlist request failed (${response.status}${detail ? `: ${detail}` : ""})`);
71
+ }
72
+ const body = await response.json();
73
+ if (!body || typeof body.netlist !== "object" || body.netlist === null) throw new NetlistError("INTERNAL", "eda-host: malformed netlist response from hq-edge");
74
+ const netlist = body.netlist;
75
+ netlist.components ??= [];
76
+ netlist.nets ??= [];
77
+ return netlist;
78
+ }
79
+ return {
80
+ getSelectionNetlist: () => fetchScope("selection"),
81
+ getProjectNetlist: () => fetchScope("project"),
82
+ getActivePageNetlist: () => fetchScope("active-page")
83
+ };
84
+ }
85
+ //#endregion
86
+ //#region src/tools.ts
87
+ /**
88
+ * Agent tools for `@huaqiu/dsh-eda-host`.
89
+ *
90
+ * Three semantic operations, one per netlist scope:
91
+ *
92
+ * get_project_netlist complete logical netlist of the current project
93
+ * get_selection_netlist netlist of the currently selected components
94
+ * get_active_page_netlist netlist of the active schematic page
95
+ *
96
+ * The tools are pure pass-throughs: they call the hq-edge netlist router and
97
+ * return the semantic `SchematicNetlist` as lossless JSON. Errors are
98
+ * propagated with a semantic `kind` (FAILED_PRECONDITION / UNIMPLEMENTED /
99
+ * INTERNAL / UNAVAILABLE) — never converted into a fake empty netlist. A
100
+ * valid-but-empty netlist is `ok: true` with empty `components`/`nets`.
101
+ *
102
+ * @module
103
+ */
104
+ /** The normalized domain values are lossless-JSON plain objects. */
105
+ function asJson(value) {
106
+ return JSON.parse(JSON.stringify(value));
107
+ }
108
+ function renderJson(_args, value) {
109
+ return [{
110
+ type: "text",
111
+ text: JSON.stringify(value)
112
+ }];
113
+ }
114
+ async function runScope(env, scope) {
115
+ try {
116
+ let netlist;
117
+ if (scope === "project") netlist = await env.client.getProjectNetlist();
118
+ else if (scope === "selection") netlist = await env.client.getSelectionNetlist();
119
+ else netlist = await env.client.getActivePageNetlist();
120
+ return {
121
+ ok: true,
122
+ scope,
123
+ netlist
124
+ };
125
+ } catch (err) {
126
+ return {
127
+ ok: false,
128
+ scope,
129
+ error: {
130
+ kind: err instanceof NetlistError ? err.kind : "INTERNAL",
131
+ message: String(err?.message ?? err)
132
+ }
133
+ };
134
+ }
135
+ }
136
+ function scopeDescription(scope, extra) {
137
+ 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.";
138
+ }
139
+ function createNetListTools(env) {
140
+ const mkTool = (scope, name, desc) => defineTool({
141
+ name,
142
+ description: desc,
143
+ parameters: {},
144
+ output: {
145
+ schema: { type: "json" },
146
+ render: renderJson
147
+ },
148
+ async execute(_args, _exec) {
149
+ return asJson(await runScope(env, scope));
150
+ }
151
+ });
152
+ return [
153
+ mkTool("project", "get_project_netlist", scopeDescription("project", "Returns the complete logical netlist for the current project (all sheets, all nets).")),
154
+ 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).")),
155
+ 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\"."))
156
+ ];
157
+ }
158
+ //#endregion
159
+ //#region src/index.ts
160
+ /** Plugin id — matches package.json. */
161
+ const name = "@huaqiu/dsh-eda-host";
162
+ /** Cordis services this half depends on. */
163
+ const inject = ["tools"];
164
+ const log = getLogger("dsh-eda-host");
165
+ /**
166
+ * Host plugin body — provide `edaHost` and register the three netlist tools.
167
+ *
168
+ * When no host base URL is configured, tools return ok:false with
169
+ * error.kind "FAILED_PRECONDITION" instead of throwing at load time: the
170
+ * plugin can be installed in standalone DSH where hq-edge is absent, and the
171
+ * tools degrade to a clear semantic message.
172
+ *
173
+ * @param ctx - real cordis context (node side).
174
+ * @returns disposer — unregisters the tools on plugin dispose.
175
+ */
176
+ function apply(ctx, config = {}) {
177
+ if (!ctx.tools || typeof ctx.tools.register !== "function") throw new Error("@huaqiu/dsh-eda-host requires the DSH `tools` service (ctx.tools.register).");
178
+ const resolved = resolveEdaHostConfig(config);
179
+ log.info("applying dsh-eda-host node half", {
180
+ hasHost: hasHost(resolved),
181
+ hqEdgeBaseUrl: resolved.hqEdgeBaseUrl ?? null,
182
+ netlistPathPrefix: resolved.netlistPathPrefix
183
+ });
184
+ let client;
185
+ if (hasHost(resolved)) client = createEdaHostClient(resolved);
186
+ else client = {
187
+ getProjectNetlist: async () => {
188
+ 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.");
189
+ },
190
+ getSelectionNetlist: async () => {
191
+ 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.");
192
+ },
193
+ getActivePageNetlist: async () => {
194
+ 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.");
195
+ }
196
+ };
197
+ ctx.effect(() => ctx.provide("edaHost", client));
198
+ const tools = createNetListTools({ client });
199
+ const disposers = [];
200
+ for (const tool of tools) disposers.push(ctx.tools.register(tool));
201
+ log.info("dsh-eda-host node half ready", {
202
+ tools: 3,
203
+ hostMode: hasHost(resolved)
204
+ });
205
+ return () => {
206
+ for (const dispose of disposers) try {
207
+ dispose();
208
+ } catch {}
209
+ };
210
+ }
211
+ //#endregion
212
+ export { NetlistError, apply, inject, name };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@huaqiu/dsh-eda-host",
3
+ "version": "0.3.20",
4
+ "type": "module",
5
+ "main": "./lib/index.mjs",
6
+ "types": "./lib/index.d.mts",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./lib/index.d.mts",
10
+ "default": "./lib/index.mjs"
11
+ },
12
+ "./cordis.patch.yml": "./cordis.patch.yml",
13
+ "./package.json": "./package.json"
14
+ },
15
+ "dsh": {
16
+ "bundle": {
17
+ "patch": "./cordis.patch.yml"
18
+ }
19
+ },
20
+ "peerDependencies": {
21
+ "@deepseek-ai/cordis": "^4.0.1",
22
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.0"
23
+ },
24
+ "dependencies": {
25
+ "@huaqiu/dsh-plugin-log": "0.3.20"
26
+ },
27
+ "files": [
28
+ "lib",
29
+ "src",
30
+ "cordis.patch.yml"
31
+ ],
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "scripts": {
36
+ "typecheck": "tsc --noEmit",
37
+ "build": "tsdown",
38
+ "test": "vitest run"
39
+ }
40
+ }
package/src/client.ts ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Netlist transport for `@huaqiu/dsh-eda-host`.
3
+ *
4
+ * DSH → dsh-eda-host → hq-edge → EDA Host is the ONLY production path. This
5
+ * module fetches the semantic netlist from the hq-edge netlist router and
6
+ * maps the HTTP status back to the semantic gRPC error categories. It never
7
+ * parses schematic files and never touches KiCad.
8
+ *
9
+ * @module
10
+ */
11
+
12
+ import { netlistUrlOf, type EdaHostConfig, type NetlistScope } from './config.js'
13
+ import {
14
+ NetlistError,
15
+ type SchematicNetlist,
16
+ } from './types.js'
17
+
18
+ export interface EdaHostClientDeps {
19
+ fetchImpl?: typeof fetch
20
+ }
21
+
22
+ export interface EdaHostClient {
23
+ /** Netlist of the currently selected components. */
24
+ getSelectionNetlist(): Promise<SchematicNetlist>
25
+ /** Complete logical netlist for the current project. */
26
+ getProjectNetlist(): Promise<SchematicNetlist>
27
+ /** Netlist of the current active schematic page. */
28
+ getActivePageNetlist(): Promise<SchematicNetlist>
29
+ }
30
+
31
+ /** HTTP status → semantic error kind (see routes/netlist.ts on hq-edge). */
32
+ function statusToKind(status: number): NetlistError['kind'] {
33
+ if (status === 412) return 'FAILED_PRECONDITION'
34
+ if (status === 501) return 'UNIMPLEMENTED'
35
+ if (status === 503) return 'UNAVAILABLE'
36
+ return 'INTERNAL'
37
+ }
38
+
39
+ export function createEdaHostClient(
40
+ config: EdaHostConfig,
41
+ deps: EdaHostClientDeps = {},
42
+ ): EdaHostClient {
43
+ const fetchImpl = deps.fetchImpl ?? globalThis.fetch
44
+
45
+ async function fetchScope(scope: NetlistScope): Promise<SchematicNetlist> {
46
+ const url = netlistUrlOf(config, scope)
47
+
48
+ let response: Response
49
+ try {
50
+ response = await fetchImpl(url, { method: 'GET', headers: { Accept: 'application/json' } })
51
+ } catch (err) {
52
+ // Connection-level failure: host not running / unreachable.
53
+ throw new NetlistError(
54
+ 'UNAVAILABLE',
55
+ `eda-host: cannot reach hq-edge at ${url}: ${String((err as Error)?.message ?? err)}`,
56
+ )
57
+ }
58
+
59
+ if (!response.ok) {
60
+ let detail = ''
61
+ try {
62
+ const body = (await response.json()) as { detail?: unknown }
63
+ if (typeof body.detail === 'string') detail = body.detail
64
+ } catch {
65
+ // non-JSON error body — fall through with empty detail
66
+ }
67
+ throw new NetlistError(
68
+ statusToKind(response.status),
69
+ `eda-host: netlist request failed (${response.status}${detail ? `: ${detail}` : ''})`,
70
+ )
71
+ }
72
+
73
+ // Valid (possibly empty) result: { netlist: { components, nets } }.
74
+ const body = (await response.json()) as { netlist?: SchematicNetlist }
75
+ if (!body || typeof body.netlist !== 'object' || body.netlist === null) {
76
+ throw new NetlistError('INTERNAL', 'eda-host: malformed netlist response from hq-edge')
77
+ }
78
+
79
+ const netlist = body.netlist
80
+ netlist.components ??= []
81
+ netlist.nets ??= []
82
+ return netlist
83
+ }
84
+
85
+ return {
86
+ getSelectionNetlist: () => fetchScope('selection'),
87
+ getProjectNetlist: () => fetchScope('project'),
88
+ getActivePageNetlist: () => fetchScope('active-page'),
89
+ }
90
+ }
package/src/config.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Runtime config resolution for `@huaqiu/dsh-eda-host`.
3
+ *
4
+ * The plugin never opens a direct connection to the EDA host — it only talks
5
+ * to `hq-edge`, which owns the host session. The base URL is delivered by the
6
+ * hq-edge supervisor as overlay config (`hqEdgeBaseUrl`), with the
7
+ * `HQ_EDGE_BASE_URL` env as fallback for non-supervisor installs. This mirrors
8
+ * the `@huaqiu/dsh-auth` host-config convention.
9
+ *
10
+ * @module
11
+ */
12
+
13
+ export interface EdaHostConfig {
14
+ /** HQ Edge base URL, e.g. "http://localhost:18080". Absent → no host. */
15
+ hqEdgeBaseUrl?: string
16
+ /** Path prefix on the host; default "/api/v1/netlist". */
17
+ netlistPathPrefix?: string
18
+ }
19
+
20
+ export const DEFAULT_NETLIST_PATH_PREFIX = '/api/v1/netlist'
21
+
22
+ /** Scope → route suffix on the host netlist router. */
23
+ export type NetlistScope = 'project' | 'selection' | 'active-page'
24
+
25
+ export const SCOPE_ROUTE: Record<NetlistScope, string> = {
26
+ project: '/project',
27
+ selection: '/selection',
28
+ 'active-page': '/active-page',
29
+ }
30
+
31
+ export function resolveEdaHostConfig(
32
+ config?: Partial<EdaHostConfig> | null,
33
+ env: NodeJS.ProcessEnv = process.env,
34
+ ): EdaHostConfig {
35
+ const baseUrl = config?.hqEdgeBaseUrl ?? env.HQ_EDGE_BASE_URL ?? ''
36
+ const pathPrefix =
37
+ config?.netlistPathPrefix ?? env.HQ_EDGE_NETLIST_PATH ?? DEFAULT_NETLIST_PATH_PREFIX
38
+ return {
39
+ hqEdgeBaseUrl: baseUrl,
40
+ netlistPathPrefix: pathPrefix,
41
+ }
42
+ }
43
+
44
+ /** True when a host base URL is available (host mode). */
45
+ export function hasHost(config: EdaHostConfig): boolean {
46
+ return typeof config.hqEdgeBaseUrl === 'string' && config.hqEdgeBaseUrl.trim().length > 0
47
+ }
48
+
49
+ /** Build the absolute URL for one netlist scope. */
50
+ export function netlistUrlOf(config: EdaHostConfig, scope: NetlistScope): string {
51
+ const base = (config.hqEdgeBaseUrl ?? '').replace(/\/+$/, '')
52
+ const prefix = (config.netlistPathPrefix ?? DEFAULT_NETLIST_PATH_PREFIX).replace(/^\/+|\/+$/g, '')
53
+ return `${base}/${prefix}${SCOPE_ROUTE[scope]}`
54
+ }
package/src/index.ts ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `@huaqiu/dsh-eda-host` — node plugin entry.
3
+ *
4
+ * Provides the `edaHost` service (semantic EDA-host capability) and registers
5
+ * three agent tools:
6
+ *
7
+ * get_project_netlist complete project netlist
8
+ * get_selection_netlist currently selected components netlist
9
+ * get_active_page_netlist active schematic page netlist
10
+ *
11
+ * ── Architectural boundary (task: add-dsh-eda-host) ─────────────────────────
12
+ * The ONLY production request path is DSH → dsh-eda-host → hq-edge → EDA host.
13
+ * This plugin owns DSH integration only: it translates tool calls into hq-edge
14
+ * requests and returns the semantic `SchematicNetlist`. It contains no
15
+ * KiCad-specific logic, no schematic parsing, and no host IPC. The plugin is
16
+ * self-contained — no `@hqedge/*` dependency; the base URL is delivered by the
17
+ * hq-edge supervisor as overlay config (`hqEdgeBaseUrl`), with
18
+ * `HQ_EDGE_BASE_URL` as env fallback (same convention as `@huaqiu/dsh-auth`).
19
+ *
20
+ * @module @huaqiu/dsh-eda-host
21
+ */
22
+ import type { Context } from '@deepseek-ai/cordis'
23
+ import { getLogger } from '@huaqiu/dsh-plugin-log'
24
+ import { createEdaHostClient, type EdaHostClient } from './client.js'
25
+ import { hasHost, resolveEdaHostConfig, type EdaHostConfig } from './config.js'
26
+ import { createNetListTools } from './tools.js'
27
+ import { NetlistError } from './types.js'
28
+
29
+ /** Plugin id — matches package.json. */
30
+ export const name = '@huaqiu/dsh-eda-host'
31
+
32
+ /** Cordis services this half depends on. */
33
+ export const inject = ['tools'] as const
34
+
35
+ export type { EdaHostConfig } from './config.js'
36
+ export type { EdaHostClient } from './client.js'
37
+ export type {
38
+ ElectricalNet,
39
+ ElectricalType,
40
+ NetlistErrorKind,
41
+ PinDefinition,
42
+ PinReference,
43
+ SchematicComponent,
44
+ SchematicNetlist,
45
+ } from './types.js'
46
+ export { NetlistError } from './types.js'
47
+
48
+ declare module '@deepseek-ai/cordis' {
49
+ interface Context {
50
+ edaHost: EdaHostClient
51
+ }
52
+ }
53
+
54
+ /** Shared component name for the unified DSH-plugin log. */
55
+ const COMPONENT = 'dsh-eda-host'
56
+ const log = getLogger(COMPONENT)
57
+
58
+ /**
59
+ * Host plugin body — provide `edaHost` and register the three netlist tools.
60
+ *
61
+ * When no host base URL is configured, tools return ok:false with
62
+ * error.kind "FAILED_PRECONDITION" instead of throwing at load time: the
63
+ * plugin can be installed in standalone DSH where hq-edge is absent, and the
64
+ * tools degrade to a clear semantic message.
65
+ *
66
+ * @param ctx - real cordis context (node side).
67
+ * @returns disposer — unregisters the tools on plugin dispose.
68
+ */
69
+ export function apply(ctx: Context, config: Partial<EdaHostConfig> = {}): () => void {
70
+ if (!ctx.tools || typeof ctx.tools.register !== 'function') {
71
+ throw new Error(
72
+ '@huaqiu/dsh-eda-host requires the DSH `tools` service (ctx.tools.register).',
73
+ )
74
+ }
75
+
76
+ const resolved = resolveEdaHostConfig(config)
77
+ log.info('applying dsh-eda-host node half', {
78
+ hasHost: hasHost(resolved),
79
+ hqEdgeBaseUrl: resolved.hqEdgeBaseUrl ?? null,
80
+ netlistPathPrefix: resolved.netlistPathPrefix,
81
+ })
82
+
83
+ let client: EdaHostClient
84
+
85
+ if (hasHost(resolved)) {
86
+ client = createEdaHostClient(resolved)
87
+ } else {
88
+ // Standalone install (no hq-edge supervisor): every scope degrades to
89
+ // FAILED_PRECONDITION with a clear message.
90
+ const unavailable: EdaHostClient = {
91
+ getProjectNetlist: async () => {
92
+ throw new NetlistError(
93
+ 'FAILED_PRECONDITION',
94
+ 'eda-host: no hq-edge base URL configured (hqEdgeBaseUrl / HQ_EDGE_BASE_URL) — ' +
95
+ 'netlist tools require the hq-edge EDA host bridge.',
96
+ )
97
+ },
98
+ getSelectionNetlist: async () => {
99
+ throw new NetlistError(
100
+ 'FAILED_PRECONDITION',
101
+ 'eda-host: no hq-edge base URL configured (hqEdgeBaseUrl / HQ_EDGE_BASE_URL) — ' +
102
+ 'netlist tools require the hq-edge EDA host bridge.',
103
+ )
104
+ },
105
+ getActivePageNetlist: async () => {
106
+ throw new NetlistError(
107
+ 'FAILED_PRECONDITION',
108
+ 'eda-host: no hq-edge base URL configured (hqEdgeBaseUrl / HQ_EDGE_BASE_URL) — ' +
109
+ 'netlist tools require the hq-edge EDA host bridge.',
110
+ )
111
+ },
112
+ }
113
+ client = unavailable
114
+ }
115
+
116
+ ctx.effect(() => ctx.provide('edaHost', client))
117
+
118
+ const tools = createNetListTools({ client })
119
+ const disposers: Array<() => void> = []
120
+ for (const tool of tools) {
121
+ disposers.push(ctx.tools.register(tool))
122
+ }
123
+
124
+ log.info('dsh-eda-host node half ready', { tools: 3, hostMode: hasHost(resolved) })
125
+
126
+ return () => {
127
+ for (const dispose of disposers) {
128
+ try {
129
+ dispose()
130
+ } catch {
131
+ // best-effort teardown
132
+ }
133
+ }
134
+ }
135
+ }
package/src/tools.ts ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Agent tools for `@huaqiu/dsh-eda-host`.
3
+ *
4
+ * Three semantic operations, one per netlist scope:
5
+ *
6
+ * get_project_netlist complete logical netlist of the current project
7
+ * get_selection_netlist netlist of the currently selected components
8
+ * get_active_page_netlist netlist of the active schematic page
9
+ *
10
+ * The tools are pure pass-throughs: they call the hq-edge netlist router and
11
+ * return the semantic `SchematicNetlist` as lossless JSON. Errors are
12
+ * propagated with a semantic `kind` (FAILED_PRECONDITION / UNIMPLEMENTED /
13
+ * INTERNAL / UNAVAILABLE) — never converted into a fake empty netlist. A
14
+ * valid-but-empty netlist is `ok: true` with empty `components`/`nets`.
15
+ *
16
+ * @module
17
+ */
18
+
19
+ import { defineTool } from '@deepseek-ai/dsh-tools'
20
+ import type { EdaHostClient } from './client.js'
21
+ import { NetlistError, type SchematicNetlist } from './types.js'
22
+
23
+ /** Structural alias of the DSH `JsonValue`. */
24
+ type Json = string | number | boolean | null | Json[] | { [key: string]: Json }
25
+
26
+ /** The normalized domain values are lossless-JSON plain objects. */
27
+ function asJson<T>(value: T): Json {
28
+ return JSON.parse(JSON.stringify(value)) as Json
29
+ }
30
+
31
+ function renderJson(_args: unknown, value: unknown) {
32
+ return [{ type: 'text' as const, text: JSON.stringify(value) }]
33
+ }
34
+
35
+ export type NetlistScopeKind = 'project' | 'selection' | 'active_page'
36
+
37
+ export interface NetlistToolEnv {
38
+ client: EdaHostClient
39
+ }
40
+
41
+ /** Tool execution context (structural view of DSH's ToolRunContext). */
42
+ export interface ToolExecLike {
43
+ signal?: AbortSignal
44
+ callId?: string
45
+ }
46
+
47
+ type ScopeResult =
48
+ | { ok: true; scope: NetlistScopeKind; netlist: SchematicNetlist }
49
+ | { ok: false; scope: NetlistScopeKind; error: { kind: string; message: string } }
50
+
51
+ async function runScope(
52
+ env: NetlistToolEnv,
53
+ scope: NetlistScopeKind,
54
+ ): Promise<ScopeResult> {
55
+ try {
56
+ let netlist: SchematicNetlist
57
+ if (scope === 'project') netlist = await env.client.getProjectNetlist()
58
+ else if (scope === 'selection') netlist = await env.client.getSelectionNetlist()
59
+ else netlist = await env.client.getActivePageNetlist()
60
+
61
+ return { ok: true, scope, netlist }
62
+ } catch (err) {
63
+ const kind =
64
+ err instanceof NetlistError
65
+ ? err.kind
66
+ : ('INTERNAL' as const)
67
+ const message = String((err as Error)?.message ?? err)
68
+ return { ok: false, scope, error: { kind, message } }
69
+ }
70
+ }
71
+
72
+ function scopeDescription(scope: NetlistScopeKind, extra: string): string {
73
+ return (
74
+ `Read the current schematic netlist from the EDA host through hq-edge ` +
75
+ `(DSH → dsh-eda-host → hq-edge → EDA host). ` +
76
+ extra +
77
+ ` The result is a semantic netlist JSON: { ok, scope, netlist: { components[], nets[] } }. ` +
78
+ `Each component has referenceDesignators[], value, manufacturerPartNumber, footprint, ` +
79
+ `description and pins[] (pinNumber, pinName, electricalType); each net has name and ` +
80
+ `pinReferences[] (referenceDesignator, pinNumber). ` +
81
+ `IMPORTANT SEMANTICS: ok:true with empty components/nets is a VALID empty design — do not ` +
82
+ `treat it as a failure. On ok:false, error.kind distinguishes the cause: ` +
83
+ `"FAILED_PRECONDITION" (no EDA host / no live editor — ask the user to open the design in ` +
84
+ `the EDA editor first, then retry), "UNIMPLEMENTED" (this scope is not supported by the ` +
85
+ `current host — do NOT retry; report it to the user), "UNAVAILABLE" (hq-edge host unreachable), ` +
86
+ `"INTERNAL" (host-side failure). Do NOT fabricate netlist data.`
87
+ )
88
+ }
89
+
90
+ export function createNetListTools(env: NetlistToolEnv) {
91
+ const mkTool = (scope: NetlistScopeKind, name: string, desc: string) =>
92
+ defineTool({
93
+ name,
94
+ description: desc,
95
+ parameters: {},
96
+ output: { schema: { type: 'json' }, render: renderJson },
97
+ async execute(_args: unknown, _exec: ToolExecLike) {
98
+ return asJson(await runScope(env, scope))
99
+ },
100
+ })
101
+
102
+ return [
103
+ mkTool(
104
+ 'project',
105
+ 'get_project_netlist',
106
+ scopeDescription(
107
+ 'project',
108
+ 'Returns the complete logical netlist for the current project (all sheets, all nets).',
109
+ ),
110
+ ),
111
+ mkTool(
112
+ 'selection',
113
+ 'get_selection_netlist',
114
+ scopeDescription(
115
+ 'selection',
116
+ 'Returns the netlist associated with the currently selected schematic components, ' +
117
+ 'including the nets they participate in (each net lists every connected pin, not ' +
118
+ 'only the selected ones).',
119
+ ),
120
+ ),
121
+ mkTool(
122
+ 'active_page',
123
+ 'get_active_page_netlist',
124
+ scopeDescription(
125
+ 'active_page',
126
+ 'Returns the netlist for the currently active schematic page. NOTE: KiCad host does ' +
127
+ 'not implement this scope — expect ok:false with error.kind "UNIMPLEMENTED".',
128
+ ),
129
+ ),
130
+ ]
131
+ }
package/src/types.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Semantic netlist types for `@huaqiu/dsh-eda-host`.
3
+ *
4
+ * Self-contained structural mirror of `hq.ir.schematic.v1` (the hq-edge-owned
5
+ * semantic protobuf contract). The plugin intentionally does NOT depend on
6
+ * `@hqedge/*` — this file is the only place the wire shapes are named, so
7
+ * keeping them aligned with the proto is a one-file concern.
8
+ *
9
+ * @module
10
+ */
11
+
12
+ /** Electrical type of a pin — mirrors `hq.ir.logical.v1.ElectricalType`. */
13
+ export type ElectricalType =
14
+ | 'ELECTRICAL_TYPE_UNSPECIFIED'
15
+ | 'ELECTRICAL_TYPE_INPUT'
16
+ | 'ELECTRICAL_TYPE_OUTPUT'
17
+ | 'ELECTRICAL_TYPE_BIDIRECTIONAL'
18
+ | 'ELECTRICAL_TYPE_TRISTATE'
19
+ | 'ELECTRICAL_TYPE_PASSIVE'
20
+ | 'ELECTRICAL_TYPE_POWER_IN'
21
+ | 'ELECTRICAL_TYPE_POWER_OUT'
22
+ | 'ELECTRICAL_TYPE_OPEN_COLLECTOR'
23
+ | 'ELECTRICAL_TYPE_OPEN_EMITTER'
24
+ | 'ELECTRICAL_TYPE_NO_CONNECT'
25
+
26
+ export interface PinDefinition {
27
+ pinNumber: string
28
+ pinName: string
29
+ electricalType: ElectricalType
30
+ }
31
+
32
+ export interface SchematicComponent {
33
+ referenceDesignators: string[]
34
+ value: string
35
+ manufacturerPartNumber: string
36
+ footprint: string
37
+ description: string
38
+ pins: PinDefinition[]
39
+ }
40
+
41
+ export interface PinReference {
42
+ referenceDesignator: string
43
+ pinNumber: string
44
+ }
45
+
46
+ export interface ElectricalNet {
47
+ name: string
48
+ portName?: string
49
+ pinReferences: PinReference[]
50
+ }
51
+
52
+ export interface SchematicNetlist {
53
+ components: SchematicComponent[]
54
+ nets: ElectricalNet[]
55
+ }
56
+
57
+ /**
58
+ * Semantic error categories for netlist retrieval. Mirrors the gRPC status
59
+ * contract of `hq.ir.schematic.v1.NetListService` so an agent can distinguish
60
+ * "valid but empty" from "cannot answer at all".
61
+ */
62
+ export type NetlistErrorKind =
63
+ /** No EDA host is reachable (hq-edge not configured / host down). */
64
+ | 'FAILED_PRECONDITION'
65
+ /** The host does not implement this scope (e.g. active page on KiCad). */
66
+ | 'UNIMPLEMENTED'
67
+ /** Host-side runtime failure. */
68
+ | 'INTERNAL'
69
+ /** Host unavailable (connection refused). */
70
+ | 'UNAVAILABLE'
71
+
72
+ export class NetlistError extends Error {
73
+ readonly kind: NetlistErrorKind
74
+
75
+ constructor(kind: NetlistErrorKind, message: string) {
76
+ super(message)
77
+ this.name = 'NetlistError'
78
+ this.kind = kind
79
+ }
80
+ }