copperhead 0.5.0 → 0.7.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/README.md +34 -1
- package/dist/agent/loop.js +130 -15
- package/dist/agent/loop.js.map +1 -1
- package/dist/agent/prompts.js +2 -1
- package/dist/agent/prompts.js.map +1 -1
- package/dist/agent/providers/claude-code.js +466 -0
- package/dist/agent/providers/claude-code.js.map +1 -0
- package/dist/agent/providers/openai.js +30 -10
- package/dist/agent/providers/openai.js.map +1 -1
- package/dist/agent/recovery.js +148 -0
- package/dist/agent/recovery.js.map +1 -0
- package/dist/agent/render.js +17 -2
- package/dist/agent/render.js.map +1 -1
- package/dist/agent/response-cache.js +81 -0
- package/dist/agent/response-cache.js.map +1 -0
- package/dist/agent/tools.js +61 -4
- package/dist/agent/tools.js.map +1 -1
- package/dist/agent/transcript.js.map +1 -1
- package/dist/cli.js +47 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/create.js +486 -35
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/export.js +90 -0
- package/dist/commands/export.js.map +1 -0
- package/dist/config.js +33 -6
- package/dist/config.js.map +1 -1
- package/dist/kicad/bom-export.js +240 -0
- package/dist/kicad/bom-export.js.map +1 -0
- package/dist/kicad/bootstrap.js +166 -0
- package/dist/kicad/bootstrap.js.map +1 -0
- package/dist/kicad/fab.js +94 -0
- package/dist/kicad/fab.js.map +1 -0
- package/dist/kicad/spice.js +306 -0
- package/dist/kicad/spice.js.map +1 -0
- package/dist/kicad/symlib.js +228 -0
- package/dist/kicad/symlib.js.map +1 -0
- package/dist/memory/bom-table.js +232 -0
- package/dist/memory/bom-table.js.map +1 -0
- package/dist/memory/drift.js +33 -27
- package/dist/memory/drift.js.map +1 -1
- package/dist/util/git.js +37 -1
- package/dist/util/git.js.map +1 -1
- package/dist/util/preflight.js +37 -0
- package/dist/util/preflight.js.map +1 -1
- package/dist/util/retry.js +23 -0
- package/dist/util/retry.js.map +1 -1
- package/dist/util/tmp.js +119 -0
- package/dist/util/tmp.js.map +1 -0
- package/package.json +6 -2
- package/src/agent/loop.ts +148 -15
- package/src/agent/prompts.ts +2 -1
- package/src/agent/providers/claude-code.ts +550 -0
- package/src/agent/providers/openai.ts +33 -16
- package/src/agent/recovery.ts +162 -0
- package/src/agent/render.ts +28 -1
- package/src/agent/response-cache.ts +80 -0
- package/src/agent/tools.ts +62 -4
- package/src/agent/transcript.ts +1 -0
- package/src/agent/types.ts +18 -0
- package/src/cli.ts +52 -2
- package/src/commands/create.ts +543 -38
- package/src/commands/export.ts +117 -0
- package/src/config.ts +54 -6
- package/src/kicad/bom-export.ts +321 -0
- package/src/kicad/bootstrap.ts +181 -0
- package/src/kicad/fab.ts +121 -0
- package/src/kicad/spice.ts +399 -0
- package/src/kicad/symlib.ts +248 -0
- package/src/memory/bom-table.ts +249 -0
- package/src/memory/drift.ts +42 -32
- package/src/util/git.ts +37 -1
- package/src/util/preflight.ts +44 -0
- package/src/util/retry.ts +29 -0
- package/src/util/tmp.ts +113 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import type { Msg, Provider } from './types.js';
|
|
5
|
+
|
|
6
|
+
/** Thrown when a single provider turn blows past its watchdog deadline. */
|
|
7
|
+
export class TurnTimeoutError extends Error {
|
|
8
|
+
constructor(public readonly ms: number) {
|
|
9
|
+
super(`turn exceeded ${ms}ms without responding`);
|
|
10
|
+
this.name = 'TurnTimeoutError';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Race `fn()` against a deadline so a hung provider call cannot stall the run
|
|
16
|
+
* forever. On timeout, `onTimeout` runs (tear down the in-flight call, e.g.
|
|
17
|
+
* provider.close()) and the returned promise rejects with TurnTimeoutError; the
|
|
18
|
+
* caller decides whether to retry or fail. `ms <= 0` (or non-finite) disables the
|
|
19
|
+
* watchdog and just awaits `fn()`.
|
|
20
|
+
*/
|
|
21
|
+
export async function withTimeout<T>(
|
|
22
|
+
fn: () => Promise<T>,
|
|
23
|
+
ms: number,
|
|
24
|
+
onTimeout?: () => void | Promise<void>,
|
|
25
|
+
): Promise<T> {
|
|
26
|
+
if (!Number.isFinite(ms) || ms <= 0) return fn();
|
|
27
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
28
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
29
|
+
timer = setTimeout(() => {
|
|
30
|
+
void Promise.resolve(onTimeout?.()).catch(() => {});
|
|
31
|
+
reject(new TurnTimeoutError(ms));
|
|
32
|
+
}, ms);
|
|
33
|
+
});
|
|
34
|
+
try {
|
|
35
|
+
return await Promise.race([fn(), timeout]);
|
|
36
|
+
} finally {
|
|
37
|
+
if (timer) clearTimeout(timer);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface StageDiagnosis {
|
|
42
|
+
verdict: 'retry' | 'abort';
|
|
43
|
+
reason: string;
|
|
44
|
+
/** When retrying: concrete instructions to prepend to the next attempt. */
|
|
45
|
+
guidance?: string;
|
|
46
|
+
/** Tokens the diagnosis call itself spent, so the pipeline can fold them into
|
|
47
|
+
* the stage's cost total (F6). Absent when the call threw before a response. */
|
|
48
|
+
usage?: { inputTokens: number; outputTokens: number };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Extract the first brace-balanced JSON object from text, tolerating quoting and
|
|
52
|
+
* escaping, and interpret it as a StageDiagnosis. Anything unparseable is treated
|
|
53
|
+
* as "abort" so an ambiguous diagnosis never loops the pipeline forever. */
|
|
54
|
+
export function parseDiagnosis(text: string | null): StageDiagnosis {
|
|
55
|
+
if (!text) return { verdict: 'abort', reason: 'no diagnosis produced' };
|
|
56
|
+
const start = text.indexOf('{');
|
|
57
|
+
if (start >= 0) {
|
|
58
|
+
let depth = 0;
|
|
59
|
+
let inStr = false;
|
|
60
|
+
let esc = false;
|
|
61
|
+
for (let i = start; i < text.length; i++) {
|
|
62
|
+
const ch = text[i];
|
|
63
|
+
if (inStr) {
|
|
64
|
+
if (esc) esc = false;
|
|
65
|
+
else if (ch === '\\') esc = true;
|
|
66
|
+
else if (ch === '"') inStr = false;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (ch === '"') inStr = true;
|
|
70
|
+
else if (ch === '{') depth++;
|
|
71
|
+
else if (ch === '}' && --depth === 0) {
|
|
72
|
+
try {
|
|
73
|
+
const o = JSON.parse(text.slice(start, i + 1)) as Partial<StageDiagnosis>;
|
|
74
|
+
const verdict = o.verdict === 'retry' ? 'retry' : 'abort';
|
|
75
|
+
return {
|
|
76
|
+
verdict,
|
|
77
|
+
reason: typeof o.reason === 'string' ? o.reason : 'no reason given',
|
|
78
|
+
...(verdict === 'retry' && typeof o.guidance === 'string' && o.guidance.trim()
|
|
79
|
+
? { guidance: o.guidance.trim() }
|
|
80
|
+
: {}),
|
|
81
|
+
};
|
|
82
|
+
} catch {
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { verdict: 'abort', reason: 'diagnosis was not valid JSON' };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Compact, most-recent-last excerpt of a run's transcript for the diagnostician:
|
|
92
|
+
* the last assistant message and the last few tool results, truncated. */
|
|
93
|
+
export async function transcriptExcerpt(transcriptDir: string, maxChars = 4000): Promise<string> {
|
|
94
|
+
const p = path.join(transcriptDir, 'transcript.jsonl');
|
|
95
|
+
if (!existsSync(p)) return '(no transcript)';
|
|
96
|
+
let lines: string[];
|
|
97
|
+
try {
|
|
98
|
+
lines = (await readFile(p, 'utf8')).trim().split('\n');
|
|
99
|
+
} catch {
|
|
100
|
+
return '(transcript unreadable)';
|
|
101
|
+
}
|
|
102
|
+
const parts: string[] = [];
|
|
103
|
+
for (const line of lines.slice(-12)) {
|
|
104
|
+
try {
|
|
105
|
+
const e = JSON.parse(line) as { type: string; data?: Record<string, unknown> };
|
|
106
|
+
if (e.type === 'assistant' && typeof e.data?.text === 'string' && e.data.text) {
|
|
107
|
+
parts.push(`[assistant] ${e.data.text}`);
|
|
108
|
+
} else if (e.type === 'tool') {
|
|
109
|
+
parts.push(`[${String(e.data?.name)}] ${String(e.data?.result ?? '').split('\n')[0]}`);
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
/* skip */
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const joined = parts.join('\n');
|
|
116
|
+
return joined.length > maxChars ? joined.slice(joined.length - maxChars) : joined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Ask the model whether a failed/incomplete stage is worth retrying, and if so
|
|
121
|
+
* how. Uses a fresh, tool-less provider turn (the same saved-login backend the
|
|
122
|
+
* pipeline runs on), so no extra credentials or config are needed. Any error or
|
|
123
|
+
* ambiguity resolves to "abort" — recovery must fail safe toward reporting to the
|
|
124
|
+
* human rather than looping.
|
|
125
|
+
*/
|
|
126
|
+
export async function diagnoseStageFailure(
|
|
127
|
+
provider: Provider,
|
|
128
|
+
input: {
|
|
129
|
+
stageName: string;
|
|
130
|
+
stageGoal: string;
|
|
131
|
+
failure: string;
|
|
132
|
+
excerpt: string;
|
|
133
|
+
attempt: number;
|
|
134
|
+
maxAttempts: number;
|
|
135
|
+
},
|
|
136
|
+
): Promise<StageDiagnosis> {
|
|
137
|
+
const system =
|
|
138
|
+
'You are the recovery supervisor for an automated KiCad PCB-design pipeline. ' +
|
|
139
|
+
'A stage just failed or ended without meeting its completion contract. Judge whether ' +
|
|
140
|
+
'another automated attempt is likely to succeed, or whether a human should intervene. ' +
|
|
141
|
+
'Be decisive and terse.';
|
|
142
|
+
const user =
|
|
143
|
+
`Stage: ${input.stageName}\n` +
|
|
144
|
+
`Stage goal: ${input.stageGoal}\n` +
|
|
145
|
+
`Failure: ${input.failure}\n` +
|
|
146
|
+
`This was attempt ${input.attempt} of ${input.maxAttempts}.\n\n` +
|
|
147
|
+
`Recent transcript (most recent last):\n${input.excerpt}\n\n` +
|
|
148
|
+
'Reply with ONLY a JSON object, no prose:\n' +
|
|
149
|
+
'{"verdict":"retry"|"abort","reason":"<one sentence>","guidance":"<if retry: concrete, specific instructions to prepend to the next attempt so it avoids this failure; otherwise empty>"}\n' +
|
|
150
|
+
'- "retry" if the failure looks transient or fixable with clearer instructions (a dropped or locked tool call, an empty/no-op edit, a skipped step, a timeout, a formatting slip).\n' +
|
|
151
|
+
'- "abort" if repeating the same attempt will not help and a human should look (missing inputs, a genuine dead-end, or the same failure already seen on a prior attempt).';
|
|
152
|
+
const messages: Msg[] = [
|
|
153
|
+
{ role: 'system', content: system },
|
|
154
|
+
{ role: 'user', content: user },
|
|
155
|
+
];
|
|
156
|
+
try {
|
|
157
|
+
const turn = await provider.chat(messages, []);
|
|
158
|
+
return { ...parseDiagnosis(turn.text), usage: turn.usage };
|
|
159
|
+
} catch (e) {
|
|
160
|
+
return { verdict: 'abort', reason: `diagnosis call failed: ${(e as Error).message}` };
|
|
161
|
+
}
|
|
162
|
+
}
|
package/src/agent/render.ts
CHANGED
|
@@ -12,6 +12,14 @@ export interface ProgressRenderer {
|
|
|
12
12
|
toolResult(name: string, firstLine: string): void;
|
|
13
13
|
/** Busy text while a provider call is in flight; null when idle. */
|
|
14
14
|
status(text: string | null): void;
|
|
15
|
+
/**
|
|
16
|
+
* Liveness signal emitted periodically while a provider turn is in flight
|
|
17
|
+
* (5.1): distinguishes a slow turn from a hung one. `elapsedMs` is time since
|
|
18
|
+
* this turn's provider call began; `streamedChars` is cumulative streamed
|
|
19
|
+
* output (0 when the provider doesn't stream — the elapsed time still tells
|
|
20
|
+
* the operator the turn is alive).
|
|
21
|
+
*/
|
|
22
|
+
heartbeat(info: { elapsedMs: number; streamedChars: number }): void;
|
|
15
23
|
/** Final outcome line; replaces the status line in interactive mode. */
|
|
16
24
|
finish(line: string): void;
|
|
17
25
|
}
|
|
@@ -41,6 +49,11 @@ export function plainRenderer(log: (line: string) => void): ProgressRenderer {
|
|
|
41
49
|
turnStart: (turn, maxTurns, tokensIn, tokensOut) => log(turnMarker(turn, maxTurns, tokensIn, tokensOut)),
|
|
42
50
|
toolResult: (name, firstLine) => log(` [${name}] ${firstLine}`),
|
|
43
51
|
status: () => {},
|
|
52
|
+
heartbeat: ({ elapsedMs, streamedChars }) =>
|
|
53
|
+
log(
|
|
54
|
+
` … still working — ${fmtDuration(elapsedMs)} elapsed` +
|
|
55
|
+
(streamedChars ? `, ~${fmtTokens(streamedChars)} chars streamed` : ' (no output yet)'),
|
|
56
|
+
),
|
|
44
57
|
finish: (line) => log(line),
|
|
45
58
|
};
|
|
46
59
|
}
|
|
@@ -68,6 +81,7 @@ export class InteractiveRenderer implements ProgressRenderer {
|
|
|
68
81
|
private maxTurns = 0;
|
|
69
82
|
private tokensIn = 0;
|
|
70
83
|
private tokensOut = 0;
|
|
84
|
+
private streamedChars = 0;
|
|
71
85
|
private busy: string | null = null;
|
|
72
86
|
private frame = 0;
|
|
73
87
|
private timer: ReturnType<typeof setInterval> | null = null;
|
|
@@ -97,7 +111,11 @@ export class InteractiveRenderer implements ProgressRenderer {
|
|
|
97
111
|
`${fmtTokens(this.tokensIn)} in / ${fmtTokens(this.tokensOut)} out`,
|
|
98
112
|
fmtDuration(Date.now() - this.startMs),
|
|
99
113
|
];
|
|
100
|
-
if (this.busy)
|
|
114
|
+
if (this.busy) {
|
|
115
|
+
// Fold streamed-output volume into the busy segment so a large turn's
|
|
116
|
+
// status line visibly grows — a hung one stays frozen (5.1).
|
|
117
|
+
parts.push(this.streamedChars ? `${this.busy} ~${fmtTokens(this.streamedChars)} ch` : this.busy);
|
|
118
|
+
}
|
|
101
119
|
const spinner = this.busy ? FRAMES[this.frame % FRAMES.length] : '·';
|
|
102
120
|
const line = `${spinner} ${parts.join(' · ')}`;
|
|
103
121
|
const width = this.out.columns ?? 80;
|
|
@@ -138,6 +156,7 @@ export class InteractiveRenderer implements ProgressRenderer {
|
|
|
138
156
|
this.maxTurns = maxTurns;
|
|
139
157
|
this.tokensIn = tokensIn;
|
|
140
158
|
this.tokensOut = tokensOut;
|
|
159
|
+
this.streamedChars = 0; // per-turn: reset so last turn's volume doesn't linger
|
|
141
160
|
this.ensureTimer();
|
|
142
161
|
this.redraw();
|
|
143
162
|
}
|
|
@@ -148,10 +167,18 @@ export class InteractiveRenderer implements ProgressRenderer {
|
|
|
148
167
|
|
|
149
168
|
status(text: string | null): void {
|
|
150
169
|
this.busy = text;
|
|
170
|
+
if (!text) this.streamedChars = 0; // turn's provider call ended
|
|
151
171
|
if (text && !this.idle) this.ensureTimer();
|
|
152
172
|
this.redraw();
|
|
153
173
|
}
|
|
154
174
|
|
|
175
|
+
heartbeat({ streamedChars }: { elapsedMs: number; streamedChars: number }): void {
|
|
176
|
+
// The spinner timer already advances elapsed time in place; the heartbeat's
|
|
177
|
+
// job here is to fold in the latest streamed-output volume and redraw.
|
|
178
|
+
this.streamedChars = streamedChars;
|
|
179
|
+
this.redraw();
|
|
180
|
+
}
|
|
181
|
+
|
|
155
182
|
finish(line: string): void {
|
|
156
183
|
if (this.statusShown) this.out.write(CLEAR_LINE);
|
|
157
184
|
this.out.write(line + '\n');
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import type { ChatOpts, Msg, Provider, ToolSchema, Turn } from './types.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Wraps a provider so each turn's `(messages, tools) -> Turn` is written to disk
|
|
9
|
+
* and replayed on an identical later call. This makes the pipeline cheap and
|
|
10
|
+
* fast to recover: a stage that is retried after a transient failure (a timed-out
|
|
11
|
+
* turn, a crash, an auto-retry) replays the responses it already paid for, from
|
|
12
|
+
* turn 1 up to the point where the inputs first diverge, instead of re-calling
|
|
13
|
+
* the model. When a retry deliberately changes the prompt (e.g. diagnosis
|
|
14
|
+
* guidance is appended), the input hash changes and the model is called fresh —
|
|
15
|
+
* so caching never pins a run to a stale, failing response.
|
|
16
|
+
*
|
|
17
|
+
* Best-effort by construction: a cache miss or any I/O error falls through to the
|
|
18
|
+
* live provider, and a hit reports zero token usage (the real spend was zero).
|
|
19
|
+
* The key is a content hash of the full message history and the advertised tool
|
|
20
|
+
* names, so any change to the conversation or available tools is a fresh call.
|
|
21
|
+
*/
|
|
22
|
+
export class CachingProvider implements Provider {
|
|
23
|
+
readonly name: string;
|
|
24
|
+
private hits = 0;
|
|
25
|
+
|
|
26
|
+
/** Turns served from the on-disk cache so far (5.2: per-stage cache-hit%). */
|
|
27
|
+
get cacheHits(): number {
|
|
28
|
+
return this.hits;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
constructor(
|
|
32
|
+
private readonly inner: Provider,
|
|
33
|
+
private readonly dir: string,
|
|
34
|
+
private readonly log?: (s: string) => void,
|
|
35
|
+
/** The concrete model id this run resolved to (e.g. `claude-code:opus`), used
|
|
36
|
+
* in the cache key so switching model on the same repo does not replay the
|
|
37
|
+
* other model's cached turns (F6). Falls back to the provider family name. */
|
|
38
|
+
private readonly modelId?: string,
|
|
39
|
+
) {
|
|
40
|
+
this.name = inner.name;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private keyFor(messages: Msg[], tools: ToolSchema[]): string {
|
|
44
|
+
return createHash('sha256')
|
|
45
|
+
.update(JSON.stringify({ model: this.modelId ?? this.name, messages, tools: tools.map((t) => t.name) }))
|
|
46
|
+
.digest('hex');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async chat(messages: Msg[], tools: ToolSchema[], opts?: ChatOpts): Promise<Turn> {
|
|
50
|
+
const file = path.join(this.dir, `${this.keyFor(messages, tools)}.json`);
|
|
51
|
+
if (existsSync(file)) {
|
|
52
|
+
try {
|
|
53
|
+
const cached = JSON.parse(await readFile(file, 'utf8')) as Turn;
|
|
54
|
+
this.hits++;
|
|
55
|
+
this.log?.(`llm-cache: replayed a cached response (hit #${this.hits}, no tokens spent)`);
|
|
56
|
+
// Report zero usage: replaying a cached turn costs nothing.
|
|
57
|
+
return { ...cached, usage: { inputTokens: 0, outputTokens: 0 } };
|
|
58
|
+
} catch {
|
|
59
|
+
// corrupt/partial cache file — fall through and regenerate
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const turn = await this.inner.chat(messages, tools, opts);
|
|
63
|
+
try {
|
|
64
|
+
await mkdir(this.dir, { recursive: true });
|
|
65
|
+
// Keep the cache out of git entirely (and out of failed-run stashes): a
|
|
66
|
+
// `*` .gitignore in the cache dir hides every entry, so the cache persists
|
|
67
|
+
// across runs without ever dirtying the tree.
|
|
68
|
+
const ignore = path.join(this.dir, '.gitignore');
|
|
69
|
+
if (!existsSync(ignore)) await writeFile(ignore, '*\n', 'utf8');
|
|
70
|
+
await writeFile(file, JSON.stringify(turn), 'utf8');
|
|
71
|
+
} catch {
|
|
72
|
+
// best-effort: caching must never break a run
|
|
73
|
+
}
|
|
74
|
+
return turn;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async close(): Promise<void> {
|
|
78
|
+
await this.inner.close?.();
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/agent/tools.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { resolveInRepo, isKicadFile } from '../util/paths.js';
|
|
|
6
6
|
import { runErc, runDrc, exportSvg, exportFab, kicadLoadError, isProbeableKicadFile } from '../kicad/cli.js';
|
|
7
7
|
import { formatViolations, type CheckReport } from '../kicad/report.js';
|
|
8
8
|
import { listSymbols, listNets } from '../kicad/sexp.js';
|
|
9
|
+
import { verifySchematicSymbols } from '../kicad/symlib.js';
|
|
9
10
|
import { checkDrift } from '../memory/drift.js';
|
|
10
11
|
import { saveConstraint, classifyAffectsTarget, affectsTargetExists } from '../memory/constraints.js';
|
|
11
12
|
import { openspecValidate } from '../openspec/cli.js';
|
|
@@ -52,6 +53,24 @@ const str = (args: Record<string, unknown>, key: string): string => {
|
|
|
52
53
|
return v;
|
|
53
54
|
};
|
|
54
55
|
|
|
56
|
+
// U+FFFD (the Unicode replacement character) is what a byte sequence becomes
|
|
57
|
+
// when UTF-8 decoding fails — most often a multibyte glyph (Ω, µ, ±, °) split
|
|
58
|
+
// across a streaming chunk boundary and decoded per-chunk upstream in the
|
|
59
|
+
// provider SDK (I2). It never appears in a legitimately authored PCB doc, so
|
|
60
|
+
// its presence in a content-bearing tool arg means the value arrived corrupted.
|
|
61
|
+
// Reject the call before it lands on disk so the model re-emits; the corruption
|
|
62
|
+
// is nondeterministic (it depends on where a chunk boundary fell), so the retry
|
|
63
|
+
// almost always comes through clean — far cheaper than shipping a mangled value
|
|
64
|
+
// like "5.1kΩ" → "5.1k�" into DECISIONS.md and only noticing on review.
|
|
65
|
+
const REPLACEMENT_CHAR = '�';
|
|
66
|
+
export function corruptionError(fields: Record<string, unknown>): string | null {
|
|
67
|
+
const bad = Object.entries(fields)
|
|
68
|
+
.filter(([, v]) => typeof v === 'string' && v.includes(REPLACEMENT_CHAR))
|
|
69
|
+
.map(([k]) => k);
|
|
70
|
+
if (!bad.length) return null;
|
|
71
|
+
return `rejected: the ${bad.join(', ')} value contains U+FFFD (�), the replacement character that signals a UTF-8 decoding error — a special character (e.g. Ω, µ, ±, °) was likely mangled in transit. Re-send this exact call with the intended character written correctly, or spell it in ASCII (e.g. "ohm", "uF", "+/-", "deg").`;
|
|
72
|
+
}
|
|
73
|
+
|
|
55
74
|
function markTouched(ctx: RunContext, rel: string): void {
|
|
56
75
|
ctx.filesTouched.add(rel);
|
|
57
76
|
if (isKicadFile(rel)) {
|
|
@@ -202,7 +221,7 @@ export const TOOLS: ToolDef[] = [
|
|
|
202
221
|
schema: {
|
|
203
222
|
name: 'edit_file',
|
|
204
223
|
description:
|
|
205
|
-
'Exact-match anchored replace in an existing file. The anchor must be unique; widen it with surrounding lines if not. For renames, pass replace_all: true to replace every occurrence in one call.',
|
|
224
|
+
'Exact-match anchored replace in an existing file. Requires a validated change proposal first (call propose_change then validate_change to unlock edits; both may be in the same reply, before this call). The anchor must be unique; widen it with surrounding lines if not. For renames, pass replace_all: true to replace every occurrence in one call.',
|
|
206
225
|
parameters: {
|
|
207
226
|
type: 'object',
|
|
208
227
|
properties: {
|
|
@@ -216,6 +235,8 @@ export const TOOLS: ToolDef[] = [
|
|
|
216
235
|
},
|
|
217
236
|
requiresUnlock: true,
|
|
218
237
|
handler: async (ctx, args) => {
|
|
238
|
+
const corrupt = corruptionError({ new_string: args.new_string });
|
|
239
|
+
if (corrupt) return corrupt;
|
|
219
240
|
const rel = str(args, 'path');
|
|
220
241
|
const abs = resolveInRepo(ctx.repoRoot, rel);
|
|
221
242
|
// Text edits can corrupt an s-expression file in ways the editor cannot
|
|
@@ -256,7 +277,8 @@ export const TOOLS: ToolDef[] = [
|
|
|
256
277
|
{
|
|
257
278
|
schema: {
|
|
258
279
|
name: 'write_file',
|
|
259
|
-
description:
|
|
280
|
+
description:
|
|
281
|
+
'Create a new file (docs, outputs). Requires a validated change proposal first (propose_change then validate_change to unlock edits). Refuses to overwrite anything or to create KiCad files.',
|
|
260
282
|
parameters: {
|
|
261
283
|
type: 'object',
|
|
262
284
|
properties: { path: { type: 'string' }, content: { type: 'string' } },
|
|
@@ -265,6 +287,8 @@ export const TOOLS: ToolDef[] = [
|
|
|
265
287
|
},
|
|
266
288
|
requiresUnlock: true,
|
|
267
289
|
handler: async (ctx, args) => {
|
|
290
|
+
const corrupt = corruptionError({ content: args.content });
|
|
291
|
+
if (corrupt) return corrupt;
|
|
268
292
|
const rel = str(args, 'path');
|
|
269
293
|
const res = await toolWriteFile(ctx.repoRoot, rel, args.content as string);
|
|
270
294
|
markTouched(ctx, rel);
|
|
@@ -281,11 +305,43 @@ export const TOOLS: ToolDef[] = [
|
|
|
281
305
|
handler: async (ctx) => {
|
|
282
306
|
if (!ctx.config.schematic)
|
|
283
307
|
return 'no schematic configured; ERC does not apply yet — skip it until a schematic exists and is set in .copperhead/config.json';
|
|
284
|
-
const
|
|
308
|
+
const schPath = path.join(ctx.repoRoot, ctx.config.schematic);
|
|
309
|
+
const report = await runErc(schPath);
|
|
285
310
|
ctx.lastErc = report;
|
|
286
311
|
if (report.ok) ctx.ledger.clear('erc');
|
|
287
312
|
else ctx.repairCycles++;
|
|
288
|
-
|
|
313
|
+
const out = formatViolations(report);
|
|
314
|
+
// A zero-symbol schematic passes ERC with 0 violations — a false green
|
|
315
|
+
// (3.2) that lets a premature finish look verified (an empty sheet also
|
|
316
|
+
// passes drift). The stage contract already requires symbols>0, but a bare
|
|
317
|
+
// "ERC clean" on the empty starting sheet still misleads the model, so warn
|
|
318
|
+
// here too: no gate should read as satisfied by the empty starting state.
|
|
319
|
+
if (report.ok && !(await listSymbols(schPath)).length) {
|
|
320
|
+
return `${out}\nwarning: ERC is clean but the schematic has ZERO symbols — an empty sheet always passes ERC, so this is NOT a verified design. Capture the parts from BOM.md (and re-run run_erc) before calling finish.`;
|
|
321
|
+
}
|
|
322
|
+
return out;
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
schema: {
|
|
327
|
+
name: 'verify_symbols',
|
|
328
|
+
description:
|
|
329
|
+
"Cross-check every lib_symbols entry in the schematic against the KiCad symbol library installed on this machine. Reports pins that diverge from the real part (wrong count, name, or electrical type) and lib_ids that do not exist in the current KiCad version (with the closest real names). ERC cannot catch these — a symbol whose lib_id claims to be a canonical part but whose pins are wrong passes ERC while being wrong. Run this after capturing symbols and reconcile every finding.",
|
|
330
|
+
parameters: { type: 'object', properties: {}, required: [] },
|
|
331
|
+
},
|
|
332
|
+
requiresUnlock: false,
|
|
333
|
+
handler: async (ctx) => {
|
|
334
|
+
if (!ctx.config.schematic)
|
|
335
|
+
return 'no schematic configured; verify_symbols does not apply yet';
|
|
336
|
+
const { findings, checked, skipped } = await verifySchematicSymbols(
|
|
337
|
+
path.join(ctx.repoRoot, ctx.config.schematic),
|
|
338
|
+
);
|
|
339
|
+
if (!findings.length) {
|
|
340
|
+
return `verify_symbols: ${checked} symbol(s) match the installed KiCad library. No divergences.`;
|
|
341
|
+
}
|
|
342
|
+
const lines = findings.map((f) => ` - [${f.kind}] ${f.detail}`);
|
|
343
|
+
const mismatches = findings.filter((f) => f.kind !== 'no-library').length;
|
|
344
|
+
return `verify_symbols: ${checked} verified, ${skipped} unverifiable (library not installed), ${mismatches} issue(s) to reconcile:\n${lines.join('\n')}`;
|
|
289
345
|
},
|
|
290
346
|
},
|
|
291
347
|
{
|
|
@@ -514,6 +570,8 @@ export const TOOLS: ToolDef[] = [
|
|
|
514
570
|
},
|
|
515
571
|
requiresUnlock: true,
|
|
516
572
|
handler: async (ctx, args) => {
|
|
573
|
+
const corrupt = corruptionError({ decision: args.decision, rationale: args.rationale, affects: args.affects });
|
|
574
|
+
if (corrupt) return corrupt;
|
|
517
575
|
const decision = str(args, 'decision');
|
|
518
576
|
const rationale = str(args, 'rationale');
|
|
519
577
|
const affects = (args.affects as string | undefined) ?? '';
|
package/src/agent/transcript.ts
CHANGED
package/src/agent/types.ts
CHANGED
|
@@ -8,6 +8,7 @@ export interface ToolCall {
|
|
|
8
8
|
id: string;
|
|
9
9
|
name: string;
|
|
10
10
|
args: Record<string, unknown>;
|
|
11
|
+
extra?: Record<string, unknown>;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export type Msg =
|
|
@@ -20,10 +21,27 @@ export interface Turn {
|
|
|
20
21
|
text: string | null;
|
|
21
22
|
toolCalls: ToolCall[];
|
|
22
23
|
usage: { inputTokens: number; outputTokens: number };
|
|
24
|
+
/**
|
|
25
|
+
* A one-line steer for a turn that produced NO tool call but clearly *intended*
|
|
26
|
+
* one — e.g. a fenced ```json block that names a real tool yet fails to parse
|
|
27
|
+
* (unbalanced braces). The loop surfaces it in place of the generic
|
|
28
|
+
* "continue using tools" nudge so the model fixes the malformed call instead of
|
|
29
|
+
* misreading the silence as a broken tool (#I10). Providers that can't detect
|
|
30
|
+
* a near-miss simply never set it.
|
|
31
|
+
*/
|
|
32
|
+
nudge?: string;
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
export interface ChatOpts {
|
|
26
36
|
maxTokens?: number;
|
|
37
|
+
/**
|
|
38
|
+
* Liveness callback for the loop's heartbeat (5.1). A streaming provider calls
|
|
39
|
+
* it as output arrives, passing the cumulative streamed-output length in chars,
|
|
40
|
+
* so a slow turn can be told apart from a hung one. Providers that don't stream
|
|
41
|
+
* simply never call it (the heartbeat still reports elapsed time). Never used
|
|
42
|
+
* for billing — real token usage is reported once, on the returned Turn.
|
|
43
|
+
*/
|
|
44
|
+
onStream?: (streamedChars: number) => void;
|
|
27
45
|
}
|
|
28
46
|
|
|
29
47
|
export interface Provider {
|
package/src/cli.ts
CHANGED
|
@@ -8,6 +8,14 @@ import { runInit, InitError } from './memory/scaffold.js';
|
|
|
8
8
|
import { runCheck } from './commands/check.js';
|
|
9
9
|
import { syncVerify, syncResolve, formatSyncReport } from './commands/sync.js';
|
|
10
10
|
import { runCreate } from './commands/create.js';
|
|
11
|
+
import {
|
|
12
|
+
runExportBom,
|
|
13
|
+
parseSupplier,
|
|
14
|
+
parseBoards,
|
|
15
|
+
parseSpares,
|
|
16
|
+
ExportError,
|
|
17
|
+
} from './commands/export.js';
|
|
18
|
+
import { DEFAULT_BOARDS, DEFAULT_SPARES } from './kicad/bom-export.js';
|
|
11
19
|
import { runAgentLoop, type BudgetExhaustedStats } from './agent/loop.js';
|
|
12
20
|
import { makeRenderer } from './agent/render.js';
|
|
13
21
|
import { kicadCliVersion } from './kicad/cli.js';
|
|
@@ -117,7 +125,7 @@ program
|
|
|
117
125
|
.command('do')
|
|
118
126
|
.description('the core loop: propose, edit, verify, propagate, commit')
|
|
119
127
|
.argument('<request>', 'the change request in natural language')
|
|
120
|
-
.option('--model <model>', 'codex | gpt-5 | claude (or a provider-specific model id)')
|
|
128
|
+
.option('--model <model>', 'codex | gpt-5 | claude | claude-code (or a provider-specific model id)')
|
|
121
129
|
.option('--max-turns <n>', 'turn budget for this run')
|
|
122
130
|
.option('--allow-dirty', 'allow a dirty tree (snapshot via git stash create)')
|
|
123
131
|
.option('--dry-run', 'propose the diff, write nothing')
|
|
@@ -195,7 +203,7 @@ program
|
|
|
195
203
|
.command('create')
|
|
196
204
|
.description('Mode A: full pipeline from a product brief to the output package')
|
|
197
205
|
.requiredOption('--brief <file>', 'product brief (markdown)')
|
|
198
|
-
.option('--model <model>', 'codex | gpt-5 | claude')
|
|
206
|
+
.option('--model <model>', 'codex | gpt-5 | claude | claude-code (or a provider-specific model id)')
|
|
199
207
|
.option('--interactive', 're-enable the human gates (spec approval, pre-export)')
|
|
200
208
|
.action(async (opts: { brief: string; model?: string; interactive?: boolean }) => {
|
|
201
209
|
const repo = repoOf(program.opts());
|
|
@@ -221,6 +229,48 @@ program
|
|
|
221
229
|
}
|
|
222
230
|
});
|
|
223
231
|
|
|
232
|
+
const exportCmd = program
|
|
233
|
+
.command('export')
|
|
234
|
+
.description('emit supplier-ready files from repo state (deterministic; no LLM, no network)');
|
|
235
|
+
|
|
236
|
+
exportCmd
|
|
237
|
+
.command('bom')
|
|
238
|
+
.description('write a supplier-format BOM (jlcpcb | digikey | mouser) from docs/BOM.md')
|
|
239
|
+
.requiredOption('--supplier <name>', 'jlcpcb | digikey | mouser')
|
|
240
|
+
.option('--boards <n>', 'number of boards to order', String(DEFAULT_BOARDS))
|
|
241
|
+
.option('--spares <percent>', 'spare parts percentage', String(DEFAULT_SPARES))
|
|
242
|
+
.option('--include-unverified', 'include UNVERIFIED rows that carry an MPN (never MPN-less rows)')
|
|
243
|
+
.action(async (opts: { supplier: string; boards: string; spares: string; includeUnverified?: boolean }) => {
|
|
244
|
+
const repo = repoOf(program.opts());
|
|
245
|
+
const json = Boolean(program.opts().json);
|
|
246
|
+
try {
|
|
247
|
+
const supplier = parseSupplier(opts.supplier);
|
|
248
|
+
const boards = parseBoards(opts.boards);
|
|
249
|
+
const spares = parseSpares(opts.spares);
|
|
250
|
+
const res = await runExportBom({
|
|
251
|
+
repoRoot: repo,
|
|
252
|
+
supplier,
|
|
253
|
+
boards,
|
|
254
|
+
spares,
|
|
255
|
+
includeUnverified: opts.includeUnverified ?? false,
|
|
256
|
+
});
|
|
257
|
+
// Warnings go to stderr so a `> file` redirect of stdout stays clean and
|
|
258
|
+
// the excluded-rows report is still seen.
|
|
259
|
+
for (const w of res.warnings) console.error(w);
|
|
260
|
+
if (json) {
|
|
261
|
+
console.log(JSON.stringify(res, null, 2));
|
|
262
|
+
} else {
|
|
263
|
+
console.log(`wrote ${res.outPath} (${res.included.length} part(s), ${res.excluded.length} excluded)`);
|
|
264
|
+
}
|
|
265
|
+
process.exit(0);
|
|
266
|
+
} catch (err) {
|
|
267
|
+
// ExportError carries an actionable message (bad flag, missing BOM, drift);
|
|
268
|
+
// anything else is unexpected. Both exit non-zero with no stack trace.
|
|
269
|
+
console.error(err instanceof ExportError ? err.message : (err as Error).message);
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
224
274
|
program.parseAsync().catch((err: Error) => {
|
|
225
275
|
console.error(err.message);
|
|
226
276
|
process.exit(1);
|