copperhead 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +1 -1
- package/README.md +47 -8
- package/dist/agent/context.js +2 -0
- package/dist/agent/context.js.map +1 -0
- package/dist/agent/dock-renderer.js +2 -2
- package/dist/agent/dock-renderer.js.map +1 -1
- package/dist/agent/envelope.js +105 -0
- package/dist/agent/envelope.js.map +1 -0
- package/dist/agent/filetools.js +24 -1
- package/dist/agent/filetools.js.map +1 -1
- package/dist/agent/ledger.js +24 -0
- package/dist/agent/ledger.js.map +1 -1
- package/dist/agent/loop.js +63 -72
- package/dist/agent/loop.js.map +1 -1
- package/dist/agent/prompts.js +4 -3
- package/dist/agent/prompts.js.map +1 -1
- package/dist/agent/providers/claude-code.js +17 -1
- package/dist/agent/providers/claude-code.js.map +1 -1
- package/dist/agent/providers/codex.js +84 -39
- package/dist/agent/providers/codex.js.map +1 -1
- package/dist/agent/providers/tool-protocol.js +21 -0
- package/dist/agent/providers/tool-protocol.js.map +1 -1
- package/dist/agent/recovery.js +186 -15
- package/dist/agent/recovery.js.map +1 -1
- package/dist/agent/registry.js +49 -0
- package/dist/agent/registry.js.map +1 -0
- package/dist/agent/render.js +2 -2
- package/dist/agent/render.js.map +1 -1
- package/dist/agent/theme.js +10 -5
- package/dist/agent/theme.js.map +1 -1
- package/dist/agent/tools.js +100 -586
- package/dist/agent/tools.js.map +1 -1
- package/dist/agent/transcript.js +2 -0
- package/dist/agent/transcript.js.map +1 -1
- package/dist/capabilities/define.js +35 -0
- package/dist/capabilities/define.js.map +1 -0
- package/dist/capabilities/handlers.js +744 -0
- package/dist/capabilities/handlers.js.map +1 -0
- package/dist/capabilities/helpers.js +39 -0
- package/dist/capabilities/helpers.js.map +1 -0
- package/dist/capabilities/index.js +50 -0
- package/dist/capabilities/index.js.map +1 -0
- package/dist/capabilities/skills/generate-report.js +23 -0
- package/dist/capabilities/skills/generate-report.js.map +1 -0
- package/dist/cli.js +158 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/check.js +33 -1
- package/dist/commands/check.js.map +1 -1
- package/dist/commands/create.js +181 -26
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/doctor.js +83 -6
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/skill.js +109 -0
- package/dist/commands/skill.js.map +1 -0
- package/dist/commands/sync.js +3 -1
- package/dist/commands/sync.js.map +1 -1
- package/dist/config.js +19 -6
- package/dist/config.js.map +1 -1
- package/dist/kicad/bootstrap.js +24 -3
- package/dist/kicad/bootstrap.js.map +1 -1
- package/dist/kicad/cli.js +106 -18
- package/dist/kicad/cli.js.map +1 -1
- package/dist/kicad/dossier.js +207 -0
- package/dist/kicad/dossier.js.map +1 -0
- package/dist/kicad/draft/draft.js +135 -0
- package/dist/kicad/draft/draft.js.map +1 -0
- package/dist/kicad/draft/engine.js +5310 -0
- package/dist/kicad/draft/engine.js.map +1 -0
- package/dist/kicad/draft/ir.js +368 -0
- package/dist/kicad/draft/ir.js.map +1 -0
- package/dist/kicad/draft/symsource.js +504 -0
- package/dist/kicad/draft/symsource.js.map +1 -0
- package/dist/kicad/emit.js +220 -0
- package/dist/kicad/emit.js.map +1 -0
- package/dist/kicad/fab.js +13 -0
- package/dist/kicad/fab.js.map +1 -1
- package/dist/kicad/legibility.js +608 -0
- package/dist/kicad/legibility.js.map +1 -0
- package/dist/kicad/score.js +431 -0
- package/dist/kicad/score.js.map +1 -0
- package/dist/kicad/sexp.js +265 -6
- package/dist/kicad/sexp.js.map +1 -1
- package/dist/kicad/symlib.js +346 -16
- package/dist/kicad/symlib.js.map +1 -1
- package/dist/mcp/server.js +485 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/memory/bom-table.js +75 -39
- package/dist/memory/bom-table.js.map +1 -1
- package/dist/memory/scaffold.js +14 -1
- package/dist/memory/scaffold.js.map +1 -1
- package/dist/util/redact.js +6 -0
- package/dist/util/redact.js.map +1 -1
- package/package.json +13 -8
- package/src/agent/context.ts +35 -0
- package/src/agent/dock-renderer.ts +3 -2
- package/src/agent/envelope.ts +124 -0
- package/src/agent/filetools.ts +26 -1
- package/src/agent/ledger.ts +24 -0
- package/src/agent/loop.ts +73 -78
- package/src/agent/prompts.ts +4 -3
- package/src/agent/providers/claude-code.ts +22 -1
- package/src/agent/providers/codex.ts +91 -42
- package/src/agent/providers/tool-protocol.ts +22 -0
- package/src/agent/recovery.ts +183 -13
- package/src/agent/registry.ts +58 -0
- package/src/agent/render.ts +4 -3
- package/src/agent/theme.ts +15 -5
- package/src/agent/tools.ts +124 -628
- package/src/agent/transcript.ts +6 -0
- package/src/agent/types.ts +10 -5
- package/src/capabilities/define.ts +88 -0
- package/src/capabilities/handlers.ts +769 -0
- package/src/capabilities/helpers.ts +37 -0
- package/src/capabilities/index.ts +53 -0
- package/src/capabilities/skills/generate-report.ts +25 -0
- package/src/cli.ts +154 -0
- package/src/commands/check.ts +51 -1
- package/src/commands/create.ts +183 -21
- package/src/commands/doctor.ts +85 -6
- package/src/commands/skill.ts +127 -0
- package/src/commands/sync.ts +5 -3
- package/src/config.ts +56 -8
- package/src/kicad/bootstrap.ts +24 -3
- package/src/kicad/cli.ts +129 -18
- package/src/kicad/dossier.ts +217 -0
- package/src/kicad/draft/draft.ts +173 -0
- package/src/kicad/draft/engine.ts +5274 -0
- package/src/kicad/draft/ir.ts +416 -0
- package/src/kicad/draft/symsource.ts +549 -0
- package/src/kicad/emit.ts +300 -0
- package/src/kicad/fab.ts +15 -0
- package/src/kicad/legibility.ts +695 -0
- package/src/kicad/score.ts +502 -0
- package/src/kicad/sexp.ts +346 -6
- package/src/kicad/symlib.ts +364 -18
- package/src/mcp/server.ts +560 -0
- package/src/memory/bom-table.ts +85 -38
- package/src/memory/scaffold.ts +14 -1
- package/src/util/redact.ts +6 -0
- package/dist/memory/synap.js +0 -152
- package/dist/memory/synap.js.map +0 -1
- package/src/memory/synap.ts +0 -217
package/src/config.ts
CHANGED
|
@@ -2,9 +2,32 @@ import { readFile } from 'node:fs/promises';
|
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Optional `legibility` block: checker thresholds and per-family severity
|
|
7
|
+
* overrides (`off` disables a family). Unknown keys and invalid values are
|
|
8
|
+
* ignored by the checker's own sanitizer, so a config typo cannot crash a run.
|
|
9
|
+
*/
|
|
10
|
+
export interface LegibilityUserConfig {
|
|
11
|
+
thresholds?: {
|
|
12
|
+
gridPitch?: number;
|
|
13
|
+
minPitch?: number;
|
|
14
|
+
utilization?: number;
|
|
15
|
+
maxWireLength?: number;
|
|
16
|
+
familyCap?: number;
|
|
17
|
+
};
|
|
18
|
+
severity?: Record<string, 'error' | 'advisory' | 'off'>;
|
|
19
|
+
/** Scorer tuning: per-metric weights and the known-good composite floor. */
|
|
20
|
+
score?: {
|
|
21
|
+
weights?: Record<string, number>;
|
|
22
|
+
floor?: number;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
5
26
|
export interface CopperheadConfig {
|
|
6
27
|
schematic: string | null;
|
|
7
28
|
board: string | null;
|
|
29
|
+
/** Schematic legibility checker thresholds and severity overrides. */
|
|
30
|
+
legibility?: LegibilityUserConfig;
|
|
8
31
|
docs: string;
|
|
9
32
|
model: string | null;
|
|
10
33
|
maxTurns: number;
|
|
@@ -12,9 +35,20 @@ export interface CopperheadConfig {
|
|
|
12
35
|
stageMaxTurns?: Record<string, number>;
|
|
13
36
|
maxRepairCycles: number;
|
|
14
37
|
budgets: Record<string, number>;
|
|
15
|
-
/** Per-turn watchdog (ms). A provider turn
|
|
16
|
-
*
|
|
38
|
+
/** Per-turn inactivity watchdog (ms). A provider turn that goes this long
|
|
39
|
+
* without a response or any streamed progress is treated as hung: aborted and
|
|
40
|
+
* retried, so a hung call can't stall the run forever. A streaming provider
|
|
41
|
+
* restarts it on every progress event, so a long turn that keeps producing
|
|
42
|
+
* output is not killed; a provider that reports no progress gets it as a
|
|
43
|
+
* whole-turn deadline. <=0 disables it. */
|
|
17
44
|
turnTimeoutMs: number;
|
|
45
|
+
/** Hard cap (ms) on one provider turn that is producing output, however much
|
|
46
|
+
* progress it reports. A turn that hits it is too large, not hung, so it fails
|
|
47
|
+
* without a retry: resending the identical request would only run into the cap
|
|
48
|
+
* again. It is never shorter than turnTimeoutMs, and a turn that has reported
|
|
49
|
+
* no progress is judged by turnTimeoutMs alone. <=0 disables it; when unset it
|
|
50
|
+
* defaults to 3600000, or to disabled when turnTimeoutMs is disabled. */
|
|
51
|
+
turnMaxMs: number;
|
|
18
52
|
/** How often (ms) to emit a liveness heartbeat while a provider turn is in
|
|
19
53
|
* flight, so a slow large-output turn is distinguishable from a hung one
|
|
20
54
|
* (5.1). Fires only after the first interval, so quick turns stay silent.
|
|
@@ -56,13 +90,17 @@ export const DEFAULTS: Omit<CopperheadConfig, 'schematic' | 'board'> = {
|
|
|
56
90
|
maxTurns: 40,
|
|
57
91
|
maxRepairCycles: 5,
|
|
58
92
|
budgets: {},
|
|
59
|
-
// 10 min. A single large capture turn (a full
|
|
60
|
-
// ~40k output tokens) on the claude-code
|
|
61
|
-
// minutes; the old 5-min
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
93
|
+
// 10 min without a response or progress. A single large capture turn (a full
|
|
94
|
+
// lib_symbols + instances edit, ~40k output tokens) on the claude-code
|
|
95
|
+
// provider legitimately runs several minutes; the old 5-min whole-turn
|
|
96
|
+
// deadline killed those mid-flight. Some turns run past 10 min too, so the
|
|
97
|
+
// deadline restarts on every streamed progress event: it bounds a silent
|
|
98
|
+
// stretch, not the turn's length. A provider that cannot stream still gets it
|
|
99
|
+
// as a whole-turn deadline, so no turn that finished in time before times out now.
|
|
65
100
|
turnTimeoutMs: 600000,
|
|
101
|
+
// 60 min: the backstop for a turn that keeps streaming. Far past the largest
|
|
102
|
+
// observed turns, while a runaway generation still ends in bounded time.
|
|
103
|
+
turnMaxMs: 3600000,
|
|
66
104
|
// 30s: within one interval an operator knows a turn is alive, and a full
|
|
67
105
|
// 10-min turn emits ~20 lines — enough to distinguish slow from hung without
|
|
68
106
|
// flooding the log. Quick turns (< 30s) emit nothing.
|
|
@@ -96,6 +134,15 @@ export async function loadConfig(repoRoot: string): Promise<CopperheadConfig> {
|
|
|
96
134
|
maxRepairCycles: raw.maxRepairCycles ?? DEFAULTS.maxRepairCycles,
|
|
97
135
|
budgets: raw.budgets ?? {},
|
|
98
136
|
turnTimeoutMs: typeof raw.turnTimeoutMs === 'number' ? raw.turnTimeoutMs : DEFAULTS.turnTimeoutMs,
|
|
137
|
+
// A repo that switched the turn watchdog off gets no default cap either:
|
|
138
|
+
// before the cap existed that meant no deadline at all, and a default must
|
|
139
|
+
// not quietly bring one back. An explicit turnMaxMs still applies.
|
|
140
|
+
turnMaxMs:
|
|
141
|
+
typeof raw.turnMaxMs === 'number'
|
|
142
|
+
? raw.turnMaxMs
|
|
143
|
+
: typeof raw.turnTimeoutMs === 'number' && raw.turnTimeoutMs <= 0
|
|
144
|
+
? 0
|
|
145
|
+
: DEFAULTS.turnMaxMs,
|
|
99
146
|
heartbeatMs: typeof raw.heartbeatMs === 'number' ? raw.heartbeatMs : DEFAULTS.heartbeatMs,
|
|
100
147
|
maxStageRetries:
|
|
101
148
|
Number.isInteger(raw.maxStageRetries) && (raw.maxStageRetries as number) >= 0
|
|
@@ -106,6 +153,7 @@ export async function loadConfig(repoRoot: string): Promise<CopperheadConfig> {
|
|
|
106
153
|
...(typeof raw.apiKeyEnv === 'string' && raw.apiKeyEnv.trim() ? { apiKeyEnv: raw.apiKeyEnv.trim() } : {}),
|
|
107
154
|
...(raw.generatedHashes ? { generatedHashes: raw.generatedHashes } : {}),
|
|
108
155
|
...(raw.origin === 'create' || raw.origin === 'init' ? { origin: raw.origin } : {}),
|
|
156
|
+
...(raw.legibility && typeof raw.legibility === 'object' ? { legibility: raw.legibility } : {}),
|
|
109
157
|
};
|
|
110
158
|
}
|
|
111
159
|
|
package/src/kicad/bootstrap.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
|
-
import { writeFile } from 'node:fs/promises';
|
|
2
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { configPath, loadConfig, type CopperheadConfig } from '../config.js';
|
|
6
|
+
import { CREATE_ORIGIN } from './fab.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* The create pipeline starts from a brief with no KiCad files, but the agent
|
|
@@ -37,8 +38,8 @@ function uuidFrom(seed: string): string {
|
|
|
37
38
|
function emptySchematic(rootUuid: string): string {
|
|
38
39
|
return `(kicad_sch
|
|
39
40
|
(version 20231120)
|
|
40
|
-
(generator "
|
|
41
|
-
(generator_version "
|
|
41
|
+
(generator "copperhead-draft")
|
|
42
|
+
(generator_version "0")
|
|
42
43
|
(uuid "${rootUuid}")
|
|
43
44
|
(paper "A4")
|
|
44
45
|
(lib_symbols)
|
|
@@ -147,9 +148,25 @@ function projectFile(slug: string, rootUuid: string): string {
|
|
|
147
148
|
}
|
|
148
149
|
|
|
149
150
|
async function persist(repoRoot: string, config: CopperheadConfig): Promise<void> {
|
|
151
|
+
await mkdir(path.dirname(configPath(repoRoot)), { recursive: true });
|
|
150
152
|
await writeFile(configPath(repoRoot), JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
151
153
|
}
|
|
152
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Stamp the config as create-produced (`origin: "create"`). The marker is what
|
|
157
|
+
* scopes the legibility finish gate (`isCreateProducedRepo` feeds the
|
|
158
|
+
* obligations ledger) and the fab release gate — a gate hung on a marker
|
|
159
|
+
* nothing writes is silently inert, so `runCreate` stamps it up front and
|
|
160
|
+
* `bootstrapKicadProject` re-stamps on every (re-)scaffold, covering the
|
|
161
|
+
* rollback path that deletes an uncommitted config.
|
|
162
|
+
*/
|
|
163
|
+
export async function markCreateOrigin(repoRoot: string): Promise<void> {
|
|
164
|
+
const config = await loadConfig(repoRoot);
|
|
165
|
+
if (config.origin === CREATE_ORIGIN) return;
|
|
166
|
+
config.origin = CREATE_ORIGIN;
|
|
167
|
+
await persist(repoRoot, config);
|
|
168
|
+
}
|
|
169
|
+
|
|
153
170
|
/**
|
|
154
171
|
* Ensure a KiCad project exists and is wired into config. No-op (returns null)
|
|
155
172
|
* when config already points at a schematic on disk. If project files exist but
|
|
@@ -160,6 +177,10 @@ async function persist(repoRoot: string, config: CopperheadConfig): Promise<void
|
|
|
160
177
|
export async function bootstrapKicadProject(repoRoot: string, brief: string): Promise<string | null> {
|
|
161
178
|
const config = await loadConfig(repoRoot);
|
|
162
179
|
if (config.schematic && existsSync(path.join(repoRoot, config.schematic))) return null;
|
|
180
|
+
// Only `create` scaffolds through here, so the repo is create-produced by
|
|
181
|
+
// definition; stamping on every scaffold keeps the marker alive across the
|
|
182
|
+
// rollback-then-rescaffold path (git clean deletes an uncommitted config).
|
|
183
|
+
config.origin = CREATE_ORIGIN;
|
|
163
184
|
|
|
164
185
|
const slug = projectSlug(brief);
|
|
165
186
|
const schRel = `${slug}.kicad_sch`;
|
package/src/kicad/cli.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execa, ExecaError } from 'execa';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
3
3
|
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
@@ -7,14 +7,29 @@ import { normalizeReport, type CheckReport } from './report.js';
|
|
|
7
7
|
import { PreflightError, isNotFoundError } from '../util/preflight.js';
|
|
8
8
|
|
|
9
9
|
export class KicadCliMissingError extends PreflightError {
|
|
10
|
-
constructor() {
|
|
10
|
+
constructor(platform = process.platform) {
|
|
11
|
+
const isWin = platform === 'win32';
|
|
12
|
+
const isMac = platform === 'darwin';
|
|
13
|
+
|
|
14
|
+
const locationHint = isWin
|
|
15
|
+
? 'ensure kicad-cli.exe is on PATH (typically in C:\\Program Files\\KiCad\\<version>\\bin)'
|
|
16
|
+
: isMac
|
|
17
|
+
? 'ensure the kicad-cli binary is on PATH (on macOS it ships inside KiCad.app/Contents/MacOS)'
|
|
18
|
+
: 'ensure the kicad-cli binary is on PATH (typically /usr/bin/kicad-cli)';
|
|
19
|
+
|
|
20
|
+
const envHint = isWin
|
|
21
|
+
? 'or set COPPERHEAD_KICAD_CLI="C:\\Program Files\\KiCad\\10.0\\bin\\kicad-cli.exe"'
|
|
22
|
+
: isMac
|
|
23
|
+
? 'or set COPPERHEAD_KICAD_CLI=/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli'
|
|
24
|
+
: 'or set COPPERHEAD_KICAD_CLI=/usr/bin/kicad-cli';
|
|
25
|
+
|
|
11
26
|
super(
|
|
12
27
|
'kicad-cli not found on PATH',
|
|
13
28
|
'copperhead verifies every mutation with kicad-cli ERC/DRC; without it no edit can be checked, so no run can start',
|
|
14
29
|
[
|
|
15
30
|
'install KiCad ≥ 8: https://www.kicad.org/download/',
|
|
16
|
-
|
|
17
|
-
|
|
31
|
+
locationHint,
|
|
32
|
+
envHint,
|
|
18
33
|
'confirm with "kicad-cli version", then rerun',
|
|
19
34
|
],
|
|
20
35
|
);
|
|
@@ -28,15 +43,32 @@ export class KicadCliMissingError extends PreflightError {
|
|
|
28
43
|
* seen and rejected, so telling the user to set it would be nonsense.
|
|
29
44
|
*/
|
|
30
45
|
export class KicadCliBadOverrideError extends PreflightError {
|
|
31
|
-
constructor(configured: string) {
|
|
46
|
+
constructor(configured: string, platform = process.platform) {
|
|
47
|
+
const isWin = platform === 'win32';
|
|
48
|
+
const isMac = platform === 'darwin';
|
|
49
|
+
|
|
50
|
+
const checkHint = isWin
|
|
51
|
+
? `check the path in PowerShell: Test-Path "${configured}"`
|
|
52
|
+
: `check the path: ls -l "${configured}"`;
|
|
53
|
+
|
|
54
|
+
const locationHint = isWin
|
|
55
|
+
? 'on Windows the binary typically lives at C:\\Program Files\\KiCad\\<version>\\bin\\kicad-cli.exe'
|
|
56
|
+
: isMac
|
|
57
|
+
? 'on macOS the binary lives at /Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli'
|
|
58
|
+
: 'on Linux the binary typically lives at /usr/bin/kicad-cli';
|
|
59
|
+
|
|
60
|
+
const confirmHint = isWin
|
|
61
|
+
? 'confirm with "& $env:COPPERHEAD_KICAD_CLI version", then rerun'
|
|
62
|
+
: 'confirm with "$COPPERHEAD_KICAD_CLI" version, then rerun';
|
|
63
|
+
|
|
32
64
|
super(
|
|
33
65
|
`COPPERHEAD_KICAD_CLI points to a path that does not exist: ${configured}`,
|
|
34
66
|
'the override wins over PATH, so falling back silently would run a different binary than the one you named and make the failure impossible to diagnose',
|
|
35
67
|
[
|
|
36
|
-
|
|
37
|
-
|
|
68
|
+
checkHint,
|
|
69
|
+
locationHint,
|
|
38
70
|
'or unset COPPERHEAD_KICAD_CLI to fall back to PATH',
|
|
39
|
-
|
|
71
|
+
confirmHint,
|
|
40
72
|
],
|
|
41
73
|
);
|
|
42
74
|
this.name = 'KicadCliBadOverrideError';
|
|
@@ -44,20 +76,89 @@ export class KicadCliBadOverrideError extends PreflightError {
|
|
|
44
76
|
}
|
|
45
77
|
|
|
46
78
|
/** Well-known install locations when `kicad-cli` is not on PATH (macOS app bundle). */
|
|
47
|
-
const
|
|
79
|
+
const MACOS_FALLBACK_BINARIES = [
|
|
48
80
|
'/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli',
|
|
49
81
|
'/Applications/KiCad-10.0/KiCad.app/Contents/MacOS/kicad-cli',
|
|
50
82
|
'/Applications/KiCad-9.0/KiCad.app/Contents/MacOS/kicad-cli',
|
|
51
83
|
'/Applications/KiCad-8.0/KiCad.app/Contents/MacOS/kicad-cli',
|
|
52
84
|
];
|
|
53
85
|
|
|
54
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Minimum KiCad major version accepted for ERC/DRC and candidate discovery.
|
|
88
|
+
* Re-used by doctor.ts to ensure CLI discovery and environment preflight
|
|
89
|
+
* enforce the same version floor.
|
|
90
|
+
*/
|
|
91
|
+
export const MIN_KICAD_MAJOR = 8;
|
|
92
|
+
|
|
93
|
+
const DEFAULT_WIN_ROOTS = [
|
|
94
|
+
process.env.ProgramFiles ? path.join(process.env.ProgramFiles, 'KiCad') : 'C:/Program Files/KiCad',
|
|
95
|
+
'C:/Program Files/KiCad',
|
|
96
|
+
'C:/Program Files (x86)/KiCad',
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Return default fallback candidate paths for macOS and Windows standard KiCad installations.
|
|
101
|
+
* On Windows, version-numbered directories (e.g. 10.0, 9.0, 8.0) under the KiCad root
|
|
102
|
+
* are probed in descending numerical order, mirroring symlib.ts.
|
|
103
|
+
*/
|
|
104
|
+
export function defaultFallbackBinaries(winRoots: readonly string[] = DEFAULT_WIN_ROOTS): string[] {
|
|
105
|
+
const candidates: string[] = [...MACOS_FALLBACK_BINARIES];
|
|
106
|
+
const seenRoots = new Set<string>();
|
|
107
|
+
|
|
108
|
+
for (const winRoot of winRoots) {
|
|
109
|
+
const normalized = path.normalize(winRoot).toLowerCase();
|
|
110
|
+
if (seenRoots.has(normalized)) continue;
|
|
111
|
+
seenRoots.add(normalized);
|
|
112
|
+
|
|
113
|
+
if (!existsSync(winRoot)) continue;
|
|
114
|
+
try {
|
|
115
|
+
const entries = readdirSync(winRoot, { withFileTypes: true });
|
|
116
|
+
const versions = entries
|
|
117
|
+
.filter((e) => e.isDirectory())
|
|
118
|
+
.map((e) => e.name)
|
|
119
|
+
// Only include versions that meet the minimum supported KiCad major.
|
|
120
|
+
// This prevents a stale KiCad 7.x install from silently becoming the
|
|
121
|
+
// ERC/DRC binary while doctor correctly reports FAIL for it.
|
|
122
|
+
.filter((name) => {
|
|
123
|
+
const major = Number(name.split('.')[0]);
|
|
124
|
+
return Number.isFinite(major) && major >= MIN_KICAD_MAJOR;
|
|
125
|
+
})
|
|
126
|
+
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
|
|
127
|
+
|
|
128
|
+
for (const version of versions) {
|
|
129
|
+
candidates.push(
|
|
130
|
+
path.join(winRoot, version, 'bin', 'kicad-cli.exe'),
|
|
131
|
+
path.join(winRoot, version, 'bin', 'kicad-cli.cmd'),
|
|
132
|
+
path.join(winRoot, version, 'bin', 'kicad-cli.bat'),
|
|
133
|
+
path.join(winRoot, version, 'bin', 'kicad-cli'),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
// not readable / not a directory; versioned candidates unavailable, but
|
|
138
|
+
// the unversioned bin/ fallback below is still worth probing.
|
|
139
|
+
}
|
|
140
|
+
// Unversioned fallback: pushed unconditionally so a readdirSync failure
|
|
141
|
+
// (e.g. ENOTDIR) on the root itself doesn't also suppress this probe.
|
|
142
|
+
candidates.push(
|
|
143
|
+
path.join(winRoot, 'bin', 'kicad-cli.exe'),
|
|
144
|
+
path.join(winRoot, 'bin', 'kicad-cli.cmd'),
|
|
145
|
+
path.join(winRoot, 'bin', 'kicad-cli.bat'),
|
|
146
|
+
path.join(winRoot, 'bin', 'kicad-cli'),
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return candidates;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let fallbackBinariesOverride: readonly string[] | null = null;
|
|
154
|
+
let fallbackWinRootsOverride: readonly string[] | null = null;
|
|
55
155
|
|
|
56
156
|
let cachedBinary: string | null | undefined;
|
|
57
157
|
|
|
58
158
|
/**
|
|
59
159
|
* Resolve the kicad-cli executable: `COPPERHEAD_KICAD_CLI` > PATH name
|
|
60
|
-
* (`kicad-cli`). On PATH miss, `runKicad` falls back to macOS KiCad.app paths
|
|
160
|
+
* (`kicad-cli`). On PATH miss, `runKicad` falls back to macOS KiCad.app paths
|
|
161
|
+
* and Windows standard installation paths.
|
|
61
162
|
*/
|
|
62
163
|
export function resolveKicadCli(): string {
|
|
63
164
|
if (cachedBinary !== undefined) {
|
|
@@ -88,7 +189,7 @@ function envOverride(): string | null {
|
|
|
88
189
|
}
|
|
89
190
|
|
|
90
191
|
/**
|
|
91
|
-
* After ENOENT on PATH, retry with
|
|
192
|
+
* After ENOENT on PATH, retry with known install paths (macOS app bundles, Windows standard paths).
|
|
92
193
|
*
|
|
93
194
|
* Deliberately does not re-read `COPPERHEAD_KICAD_CLI`: this is reached only
|
|
94
195
|
* when resolveKicadCli() cached the bare PATH name, which in turn happens only
|
|
@@ -96,7 +197,9 @@ function envOverride(): string | null {
|
|
|
96
197
|
* override re-check here could never fire.
|
|
97
198
|
*/
|
|
98
199
|
function fallbackAfterMissing(): string {
|
|
99
|
-
|
|
200
|
+
const candidates =
|
|
201
|
+
fallbackBinariesOverride ?? defaultFallbackBinaries(fallbackWinRootsOverride ?? undefined);
|
|
202
|
+
for (const candidate of candidates) {
|
|
100
203
|
if (existsSync(candidate)) {
|
|
101
204
|
cachedBinary = candidate;
|
|
102
205
|
return candidate;
|
|
@@ -133,13 +236,21 @@ export function resetKicadCliCache(): void {
|
|
|
133
236
|
}
|
|
134
237
|
|
|
135
238
|
/**
|
|
136
|
-
* Test helper: point the app-bundle probe at fixture paths, or call with no
|
|
137
|
-
* argument to restore the
|
|
138
|
-
* exercisable on a macOS host that happens to have KiCad installed, which
|
|
139
|
-
* makes the outcome depend on the developer's machine.
|
|
239
|
+
* Test helper: point the app-bundle / binary probe at fixture paths, or call with no
|
|
240
|
+
* argument to restore the default dynamic list.
|
|
140
241
|
*/
|
|
141
242
|
export function setKicadFallbackBinaries(paths?: readonly string[]): void {
|
|
142
|
-
|
|
243
|
+
fallbackBinariesOverride = paths ?? null;
|
|
244
|
+
fallbackWinRootsOverride = null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Test helper: point the Windows installation discovery probe at fixture roots, or call
|
|
249
|
+
* with no argument to restore the default search roots.
|
|
250
|
+
*/
|
|
251
|
+
export function setKicadFallbackWinRoots(roots?: readonly string[]): void {
|
|
252
|
+
fallbackWinRootsOverride = roots ?? null;
|
|
253
|
+
fallbackBinariesOverride = null;
|
|
143
254
|
}
|
|
144
255
|
|
|
145
256
|
export async function kicadCliVersion(): Promise<string> {
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stage-4 entry pin dossier: every BOM part resolved against the installed
|
|
3
|
+
* KiCad libraries, rendered as a prompt block, so the schematic agent starts
|
|
4
|
+
* with the pin facts it would otherwise spend turns reconstructing.
|
|
5
|
+
*
|
|
6
|
+
* The BOM is frozen when stage 4 starts, which makes this computable before the
|
|
7
|
+
* first agent turn — the same insight `symbolAvailabilityFacts` applies at
|
|
8
|
+
* recovery time, moved to entry. Like that block, coverage is stated rather
|
|
9
|
+
* than implied: parts past the size cap are named as NOT INCLUDED, parts a
|
|
10
|
+
* probe error skipped are named as UNRESOLVED, and parts whose only name is
|
|
11
|
+
* too short to search are named as NOT SEARCHED — never silently dropped,
|
|
12
|
+
* because absence from the dossier must never read as absence from the
|
|
13
|
+
* libraries.
|
|
14
|
+
*
|
|
15
|
+
* Advisory only. It changes prompt content, not gates: a missing BOM, an
|
|
16
|
+
* unreadable library, or any error degrades to an empty string and the stage
|
|
17
|
+
* runs exactly as it did before this block existed.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { parseBomTable } from '../memory/bom-table.js';
|
|
21
|
+
import {
|
|
22
|
+
resolveLibrarySymbol,
|
|
23
|
+
searchInstalledSymbols,
|
|
24
|
+
listInstalledLibraries,
|
|
25
|
+
comparePinNumbers,
|
|
26
|
+
type LibPin,
|
|
27
|
+
} from './symlib.js';
|
|
28
|
+
|
|
29
|
+
/** R/C/L refdes (with optional multi-part suffix like R12A) draw from their
|
|
30
|
+
* canonical `Device:*` symbols; a two-pin table per resistor is noise. */
|
|
31
|
+
const PASSIVE_REFDES = /^[RCL]\d+[A-Za-z]?$/i;
|
|
32
|
+
|
|
33
|
+
/** `1=PE2/bidirectional 2(passive) …` — name omitted when the library leaves
|
|
34
|
+
* the pin unnamed (`~` or empty), since `1=~/passive` reads as line noise. */
|
|
35
|
+
function pinTable(pins: LibPin[]): string {
|
|
36
|
+
return [...pins]
|
|
37
|
+
.sort((a, b) => comparePinNumbers(a.number, b.number))
|
|
38
|
+
.map((p) => {
|
|
39
|
+
const name = p.name === '~' ? '' : p.name;
|
|
40
|
+
return name ? `${p.number}=${name}/${p.type}` : `${p.number}(${p.type})`;
|
|
41
|
+
})
|
|
42
|
+
.join(' ');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Render `prefix + as many names as fit + suffix` within `budget` chars; the
|
|
46
|
+
* tail that does not fit becomes "…and N more". The budget is a hard bound:
|
|
47
|
+
* when even the minimal form exceeds it, the string is truncated outright, so
|
|
48
|
+
* the trailer can never blow the size cap it exists to disclose. */
|
|
49
|
+
function boundedList(prefix: string, names: string[], suffix: string, budget: number): string {
|
|
50
|
+
let line = '';
|
|
51
|
+
let shown = 0;
|
|
52
|
+
for (let i = 0; i < names.length; i++) {
|
|
53
|
+
const sep = shown === 0 ? '' : '; ';
|
|
54
|
+
const tail = `…and ${names.length - i} more`;
|
|
55
|
+
const candidate = `${line}${sep}${names[i]}`;
|
|
56
|
+
// Reserve room for the worst-case continuation marker after this name.
|
|
57
|
+
if (prefix.length + candidate.length + tail.length + 2 + suffix.length > budget) break;
|
|
58
|
+
line = candidate;
|
|
59
|
+
shown++;
|
|
60
|
+
}
|
|
61
|
+
const rest = names.length - shown;
|
|
62
|
+
const full =
|
|
63
|
+
rest === 0
|
|
64
|
+
? `${prefix}${line}${suffix}`
|
|
65
|
+
: `${prefix}${line ? `${line}; ` : ''}…and ${rest} more${suffix}`;
|
|
66
|
+
if (full.length <= budget) return full;
|
|
67
|
+
return full.slice(0, Math.max(1, budget - 1)) + '…';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface DossierOptions {
|
|
71
|
+
/** candidate lib_ids fetched per part */
|
|
72
|
+
searchCap?: number;
|
|
73
|
+
/** bound on the complete rendered block, disclosure lines included */
|
|
74
|
+
maxChars?: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Render the dossier block from BOM markdown. Returns `''` when there is
|
|
79
|
+
* nothing to say (no rows survive the passive filter, no search dirs, or any
|
|
80
|
+
* error) — the caller injects nothing rather than an empty heading.
|
|
81
|
+
*/
|
|
82
|
+
export async function bomSymbolDossier(
|
|
83
|
+
bomMd: string,
|
|
84
|
+
dirs: string[],
|
|
85
|
+
opts: DossierOptions = {},
|
|
86
|
+
): Promise<string> {
|
|
87
|
+
const searchCap = opts.searchCap ?? 3;
|
|
88
|
+
const maxChars = opts.maxChars ?? 24_000;
|
|
89
|
+
try {
|
|
90
|
+
if (!dirs.length) return '';
|
|
91
|
+
// No readable library at all must yield no dossier, not a page of
|
|
92
|
+
// NO-INSTALLED-SYMBOL lines: a false absence claim in a machine-verified
|
|
93
|
+
// block is the exact failure mode this file exists to prevent (I15).
|
|
94
|
+
if (!(await listInstalledLibraries(dirs)).size) return '';
|
|
95
|
+
// Group refdes by primary query so a part used five times renders once.
|
|
96
|
+
// The MPN is the stronger name when present; the stage-3 scaffold's
|
|
97
|
+
// UNVERIFIED flag word is not part of it. The Value is kept as a fallback
|
|
98
|
+
// query, searched only when the MPN finds nothing — a bogus MPN over a
|
|
99
|
+
// resolvable Value must not read as NO INSTALLED SYMBOL.
|
|
100
|
+
const byQuery = new Map<string, { refs: string[]; fallback?: string }>();
|
|
101
|
+
const unsearchable: string[] = [];
|
|
102
|
+
for (const row of parseBomTable(bomMd)) {
|
|
103
|
+
if (PASSIVE_REFDES.test(row.refdes)) continue;
|
|
104
|
+
const mpn = (row.mpn ?? '').replace(/^UNVERIFIED[:\s]*/i, '').trim();
|
|
105
|
+
const value = (row.value ?? '').trim();
|
|
106
|
+
const query = mpn || value;
|
|
107
|
+
if (query.length < 3) {
|
|
108
|
+
// Disclosed, not dropped: a crystal named "8M" was never searched, and
|
|
109
|
+
// silence here would read as "checked" under a machine-verified label.
|
|
110
|
+
unsearchable.push(`${row.refdes} (${query || 'no name'})`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const entry = byQuery.get(query) ?? { refs: [] };
|
|
114
|
+
entry.refs.push(row.refdes);
|
|
115
|
+
if (mpn && value.length >= 3 && value !== mpn) entry.fallback = value;
|
|
116
|
+
byQuery.set(query, entry);
|
|
117
|
+
}
|
|
118
|
+
if (!byQuery.size && !unsearchable.length) return '';
|
|
119
|
+
|
|
120
|
+
const lines: string[] = [];
|
|
121
|
+
const overflow: string[] = [];
|
|
122
|
+
const errored: string[] = [];
|
|
123
|
+
// Reserve room for the disclosure trailers up front, so the complete
|
|
124
|
+
// rendered block — disclosures included — stays within maxChars.
|
|
125
|
+
const TRAILER_BUDGET = Math.min(1200, Math.floor(maxChars / 4));
|
|
126
|
+
const bodyBudget = maxChars - TRAILER_BUDGET;
|
|
127
|
+
let spent = 0;
|
|
128
|
+
// Once a line fails to fit, later parts skip their library scans entirely:
|
|
129
|
+
// the cap bounds the work, not just the rendered output. (Lines have a
|
|
130
|
+
// floor of ~60 chars, so a later part fitting where this one did not is
|
|
131
|
+
// rare enough not to pay a full scan hoping for it.)
|
|
132
|
+
let bodyFull = false;
|
|
133
|
+
for (const [query, { refs, fallback }] of byQuery) {
|
|
134
|
+
const who = `${refs.join(', ')} (${query})`;
|
|
135
|
+
if (bodyFull) {
|
|
136
|
+
overflow.push(who);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
let line: string;
|
|
140
|
+
try {
|
|
141
|
+
let hits = await searchInstalledSymbols(query, dirs, searchCap);
|
|
142
|
+
let matchedBy = '';
|
|
143
|
+
if (!hits.length && fallback) {
|
|
144
|
+
hits = await searchInstalledSymbols(fallback, dirs, searchCap);
|
|
145
|
+
if (hits.length) matchedBy = ` (matched by Value "${fallback}")`;
|
|
146
|
+
}
|
|
147
|
+
const top = hits[0];
|
|
148
|
+
if (!top) {
|
|
149
|
+
line = `- ${who}: NO INSTALLED SYMBOL matches — not capturable as named; substitute a part whose symbol exists (search_symbols to find one)`;
|
|
150
|
+
} else {
|
|
151
|
+
const r = await resolveLibrarySymbol(top, dirs);
|
|
152
|
+
if (r.status !== 'ok') {
|
|
153
|
+
// A hit that fails to re-resolve is a library race or parse quirk;
|
|
154
|
+
// report the candidates without claiming pins we could not read.
|
|
155
|
+
line = `- ${who}: candidates ${hits.join(', ')} — pins unreadable here; confirm with symbol_pins`;
|
|
156
|
+
} else {
|
|
157
|
+
const multi = r.units >= 2 ? ` — MULTI-UNIT (${r.units} units): the drafting engine places each unit separately under this refdes` : '';
|
|
158
|
+
const also = hits.length > 1 ? `\n also installed: ${hits.slice(1).join(', ')}` : '';
|
|
159
|
+
line = `- ${who}: ${top}${matchedBy} — ${r.pins.length} pin(s): ${pinTable(r.pins)}${multi}${also}`;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
errored.push(who); // one failed probe must not sink the block
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (spent + line.length > bodyBudget) {
|
|
167
|
+
overflow.push(who);
|
|
168
|
+
bodyFull = true;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
spent += line.length;
|
|
172
|
+
lines.push(line);
|
|
173
|
+
}
|
|
174
|
+
if (!lines.length && !overflow.length && !errored.length && !unsearchable.length) return '';
|
|
175
|
+
// Distinct disclosures: a probe error is not a size decision, an
|
|
176
|
+
// unsearchable name is neither, and labeling any of them "size cap" would
|
|
177
|
+
// misreport why coverage is missing. Each trailer shares the reserved
|
|
178
|
+
// budget so the complete block stays within maxChars.
|
|
179
|
+
const trailers: [string, string[], string][] = [];
|
|
180
|
+
if (errored.length) {
|
|
181
|
+
trailers.push([
|
|
182
|
+
'- UNRESOLVED (probe error): ',
|
|
183
|
+
errored,
|
|
184
|
+
' — the probe failed for these; an error says nothing about availability, call symbol_pins for each.',
|
|
185
|
+
]);
|
|
186
|
+
}
|
|
187
|
+
if (unsearchable.length) {
|
|
188
|
+
trailers.push([
|
|
189
|
+
'- NOT SEARCHED (name shorter than 3 chars): ',
|
|
190
|
+
unsearchable,
|
|
191
|
+
' — too short to search reliably; nothing here says whether these resolve. Give them an MPN, or verify by hand.',
|
|
192
|
+
]);
|
|
193
|
+
}
|
|
194
|
+
if (overflow.length) {
|
|
195
|
+
trailers.push([
|
|
196
|
+
`- NOT INCLUDED (size cap ${maxChars} chars): `,
|
|
197
|
+
overflow,
|
|
198
|
+
' — call symbol_pins for each; nothing above says whether these resolve.',
|
|
199
|
+
]);
|
|
200
|
+
}
|
|
201
|
+
// Allocate from what is actually left of maxChars (body lines plus the
|
|
202
|
+
// newlines join() will add), splitting the remainder across the trailers
|
|
203
|
+
// still to render — a fixed per-trailer share of the reserve could exceed
|
|
204
|
+
// the whole-block bound when the shares themselves cannot fit.
|
|
205
|
+
let used = lines.reduce((a, l) => a + l.length + 1, 0);
|
|
206
|
+
for (let i = 0; i < trailers.length; i++) {
|
|
207
|
+
const [prefix, names, suffix] = trailers[i]!;
|
|
208
|
+
const budget = Math.floor((maxChars - used) / (trailers.length - i));
|
|
209
|
+
const rendered = boundedList(prefix, names, suffix, budget);
|
|
210
|
+
lines.push(rendered);
|
|
211
|
+
used += rendered.length + 1;
|
|
212
|
+
}
|
|
213
|
+
return lines.join('\n');
|
|
214
|
+
} catch {
|
|
215
|
+
return '';
|
|
216
|
+
}
|
|
217
|
+
}
|