@huaqiu/dsh-eda-host 0.3.24 → 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 +61 -6
- package/lib/index.d.mts +106 -5
- package/lib/index.mjs +235 -46
- package/package.json +2 -2
- package/src/client.ts +217 -33
- package/src/config.ts +37 -0
- package/src/index.ts +53 -7
- package/src/tools.ts +118 -27
- package/src/types.ts +92 -0
package/README.md
CHANGED
|
@@ -1,16 +1,30 @@
|
|
|
1
1
|
# @huaqiu/dsh-eda-host
|
|
2
2
|
|
|
3
|
-
DSH plugin exposing the current EDA host
|
|
4
|
-
|
|
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
|
-
- `
|
|
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
|
|
62
|
-
(
|
|
63
|
-
|
|
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:
|
|
11
|
-
netlistPathPrefix:
|
|
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
|
-
*
|
|
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
|
|
39
|
-
* maps
|
|
40
|
-
*
|
|
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/
|
|
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
|
-
|
|
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) —
|
|
56
|
-
|
|
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
|
-
}
|
|
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:
|
|
181
|
+
throw new NetlistError(statusToKind(response.status), `eda-host: ${what} request failed (${response.status}${detail ? `: ${detail}` : ""})`);
|
|
76
182
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
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
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
206
|
-
return
|
|
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:
|
|
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.
|
|
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.
|
|
25
|
+
"@huaqiu/dsh-plugin-log": "0.3.25"
|
|
26
26
|
},
|
|
27
27
|
"files": [
|
|
28
28
|
"lib",
|