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
package/src/util/preflight.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { statfs } from 'node:fs/promises';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* A run-blocking environment failure. Distinct from a mid-run error: nothing
|
|
3
5
|
* has been written yet, so the message alone is the whole user experience.
|
|
@@ -20,3 +22,45 @@ export function formatPreflightFailure(reason: string, why: string, remedy: stri
|
|
|
20
22
|
const steps = remedy.map((step, i) => ` ${i + 1}. ${step}`);
|
|
21
23
|
return [reason, '', `why it failed: ${why}`, 'to fix:', ...steps].join('\n');
|
|
22
24
|
}
|
|
25
|
+
|
|
26
|
+
/** Default minimum free space to start a run: 2 GiB. A create run emits gerbers,
|
|
27
|
+
* STEP, SVG renders and KiCad local history; 2 GiB is comfortably above a
|
|
28
|
+
* single board's output while still catching a nearly-full disk. */
|
|
29
|
+
export const DEFAULT_MIN_FREE_BYTES = 2 * 1024 * 1024 * 1024;
|
|
30
|
+
|
|
31
|
+
const gib = (n: number): string => `${(n / 1024 / 1024 / 1024).toFixed(1)} GiB`;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Free bytes available to this (unprivileged) user on the filesystem holding
|
|
35
|
+
* `dir`, or null when the platform/Node build cannot report it — callers treat
|
|
36
|
+
* null as "unknown" and skip the check rather than blocking a legitimate run.
|
|
37
|
+
*/
|
|
38
|
+
export async function freeDiskBytes(dir: string): Promise<number | null> {
|
|
39
|
+
try {
|
|
40
|
+
const fs = await statfs(dir);
|
|
41
|
+
return fs.bavail * fs.bsize;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Refuse to start when free disk is below `minFreeBytes` (4.1). A long run can
|
|
49
|
+
* fill the disk mid-stage — gerbers/STEP/SVG plus unbounded KiCad local history
|
|
50
|
+
* — and then fail with an opaque `ENOSPC` after doing real, expensive work. A
|
|
51
|
+
* preflight fails fast with an actionable message instead. An unknown reading
|
|
52
|
+
* (unsupported platform) skips the check.
|
|
53
|
+
*/
|
|
54
|
+
export async function assertDiskSpace(dir: string, minFreeBytes = DEFAULT_MIN_FREE_BYTES): Promise<void> {
|
|
55
|
+
const free = await freeDiskBytes(dir);
|
|
56
|
+
if (free === null || free >= minFreeBytes) return;
|
|
57
|
+
throw new PreflightError(
|
|
58
|
+
`not enough free disk space to start (${gib(free)} available, ${gib(minFreeBytes)} required)`,
|
|
59
|
+
'a create run writes fabrication outputs and KiCad local history and can fill the disk mid-stage, failing with an opaque ENOSPC only after doing real work',
|
|
60
|
+
[
|
|
61
|
+
'free up space on the volume holding this repo',
|
|
62
|
+
'or lower the threshold with COPPERHEAD_MIN_FREE_MB (e.g. COPPERHEAD_MIN_FREE_MB=500)',
|
|
63
|
+
'then re-run',
|
|
64
|
+
],
|
|
65
|
+
);
|
|
66
|
+
}
|
package/src/util/retry.ts
CHANGED
|
@@ -12,6 +12,35 @@ export function isRateLimit(err: unknown): boolean {
|
|
|
12
12
|
return status === 429;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
export interface SessionLimit {
|
|
16
|
+
/** The reset moment exactly as the provider stated it (e.g. "1:40pm"), or null
|
|
17
|
+
* when the message named a limit but no parseable time. */
|
|
18
|
+
resetsAt: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Detect a saved-login SESSION / USAGE limit (claude-code, codex), distinct from
|
|
23
|
+
* an HTTP 429 rate limit and from a code bug (2.4, I13). It is not a transient
|
|
24
|
+
* blip to back off on: it names its own reset time and clears only then. Because
|
|
25
|
+
* every completed turn is already in `.copperhead/llm-cache/`, re-running after
|
|
26
|
+
* the reset replays them at ~0 tokens and resumes in place — so the right
|
|
27
|
+
* handling is a schedulable pause with the reset time surfaced, not a bare
|
|
28
|
+
* "provider error". Returns the parsed reset time (verbatim) or null when the
|
|
29
|
+
* error is not a session/usage limit.
|
|
30
|
+
*/
|
|
31
|
+
export function sessionLimit(err: unknown): SessionLimit | null {
|
|
32
|
+
const status = (err as { status?: number; statusCode?: number })?.status
|
|
33
|
+
?? (err as { statusCode?: number })?.statusCode;
|
|
34
|
+
if (status === 429) return null; // a real rate limit: handled by backoff, not a pause
|
|
35
|
+
const msg = (err as Error)?.message ?? '';
|
|
36
|
+
if (!/(session|usage|weekly)\s+limit|hit your .*limit|reached your usage|limit .*reset/i.test(msg)) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
// "resets 1:40pm", "resets at 1:40 pm", "reset at 13:40" — capture the clock text.
|
|
40
|
+
const reset = msg.match(/reset[s]?(?:\s+at)?\s+([0-9]{1,2}(?::[0-9]{2})?\s*(?:am|pm)?)/i);
|
|
41
|
+
return { resetsAt: reset?.[1]?.trim() ?? null };
|
|
42
|
+
}
|
|
43
|
+
|
|
15
44
|
/** Exponential backoff ×N for rate limits (SPEC §4.5). */
|
|
16
45
|
export async function withRetry<T>(fn: () => Promise<T>, opts: RetryOpts = {}): Promise<T> {
|
|
17
46
|
const retries = opts.retries ?? 3;
|
package/src/util/tmp.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { readdir, stat, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
/** Newest local-history entries to keep when capping `.history/` (see
|
|
6
|
+
* pruneHistoryDir). Enough to preserve a useful recovery window, bounded enough
|
|
7
|
+
* that the dir cannot grow without limit across a long run. */
|
|
8
|
+
export const DEFAULT_HISTORY_KEEP = 200;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Cap the growth of a repo's `.history/` directory (4.1). KiCad (and editor
|
|
12
|
+
* local-history) rewrite a snapshot on every project touch, so across a long
|
|
13
|
+
* run `.history/` grows without bound and was a contributor to the disk-fill
|
|
14
|
+
* halt (I8). It is gitignored, so its contents are disposable: keep the newest
|
|
15
|
+
* `keepNewest` files by mtime and remove the rest. Recursive (local history
|
|
16
|
+
* mirrors the workspace tree), best-effort (every error is swallowed — pruning
|
|
17
|
+
* housekeeping must never fail a run), and a no-op when the dir is absent or
|
|
18
|
+
* already under the cap. Returns the number of files removed.
|
|
19
|
+
*/
|
|
20
|
+
export async function pruneHistoryDir(repoRoot: string, keepNewest = DEFAULT_HISTORY_KEEP): Promise<number> {
|
|
21
|
+
const root = path.join(repoRoot, '.history');
|
|
22
|
+
const files: Array<{ full: string; mtimeMs: number }> = [];
|
|
23
|
+
const walk = async (dir: string): Promise<void> => {
|
|
24
|
+
let entries: Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>;
|
|
25
|
+
try {
|
|
26
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
27
|
+
} catch {
|
|
28
|
+
return; // unreadable dir — skip it
|
|
29
|
+
}
|
|
30
|
+
for (const e of entries) {
|
|
31
|
+
const full = path.join(dir, e.name);
|
|
32
|
+
if (e.isDirectory()) {
|
|
33
|
+
await walk(full);
|
|
34
|
+
} else if (e.isFile()) {
|
|
35
|
+
try {
|
|
36
|
+
const st = await stat(full);
|
|
37
|
+
files.push({ full, mtimeMs: st.mtimeMs });
|
|
38
|
+
} catch {
|
|
39
|
+
// stat race — skip this file
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
await walk(root);
|
|
45
|
+
if (files.length <= keepNewest) return 0;
|
|
46
|
+
files.sort((a, b) => b.mtimeMs - a.mtimeMs); // newest first
|
|
47
|
+
let removed = 0;
|
|
48
|
+
for (const f of files.slice(keepNewest)) {
|
|
49
|
+
try {
|
|
50
|
+
await rm(f.full, { force: true });
|
|
51
|
+
removed++;
|
|
52
|
+
} catch {
|
|
53
|
+
// permission/race — leave it, keep pruning the rest
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return removed;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Every scratch dir copperhead makes under the OS temp dir shares this prefix:
|
|
61
|
+
* kicad-cli ERC/DRC (`copperhead-`), the KiCad edit probe (`copperhead-validate-`),
|
|
62
|
+
* the failed-run backup (`copperhead-runs-`), and the provider working dirs
|
|
63
|
+
* (`copperhead-cc-`, `copperhead-codex-`). Each site removes its own dir in a
|
|
64
|
+
* `finally`, but a watchdog SIGKILL of the process tree or a hard abort skips
|
|
65
|
+
* that cleanup, so stale dirs accumulate across runs and can eventually fill the
|
|
66
|
+
* disk (I8). A prefix match lets one sweep reclaim all of them.
|
|
67
|
+
*/
|
|
68
|
+
export const TEMP_PREFIX = 'copperhead-';
|
|
69
|
+
|
|
70
|
+
/** Default staleness cutoff for the startup sweep: 2h. Safe even for multi-hour
|
|
71
|
+
* runs (10-min turns × per-stage retries × 8 stages): a live run's only
|
|
72
|
+
* long-lived scratch dir is the provider's reused cwd, which is `utimes`-touched
|
|
73
|
+
* every turn (ClaudeCodeProvider.ensureCwd), so its mtime never goes stale while
|
|
74
|
+
* the process is alive; per-call kicad-cli dirs are removed within a turn. A dir
|
|
75
|
+
* older than this therefore belongs to a dead run, and the window is short enough
|
|
76
|
+
* that such a leak is reclaimed on the very next invocation. */
|
|
77
|
+
export const DEFAULT_STALE_MS = 2 * 60 * 60 * 1000;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Remove leaked `copperhead-*` scratch dirs left in the OS temp dir by earlier
|
|
81
|
+
* runs whose `finally` cleanup was skipped (watchdog kill / hard abort). Only
|
|
82
|
+
* dirs whose mtime is older than `maxAgeMs` are removed, so a concurrent run's
|
|
83
|
+
* fresh scratch dirs are never touched. Best-effort: every error is swallowed
|
|
84
|
+
* (a temp dir we can't stat or remove is not worth failing a run over), and the
|
|
85
|
+
* function returns the paths it removed so a caller can log the reclaim.
|
|
86
|
+
*
|
|
87
|
+
* `now` is injected so the behaviour is deterministically testable; callers pass
|
|
88
|
+
* `Date.now()`.
|
|
89
|
+
*/
|
|
90
|
+
export async function sweepStaleTempDirs(now: number, maxAgeMs = DEFAULT_STALE_MS): Promise<string[]> {
|
|
91
|
+
const root = tmpdir();
|
|
92
|
+
const removed: string[] = [];
|
|
93
|
+
let entries: string[];
|
|
94
|
+
try {
|
|
95
|
+
entries = await readdir(root);
|
|
96
|
+
} catch {
|
|
97
|
+
return removed; // no temp dir / not readable — nothing to sweep
|
|
98
|
+
}
|
|
99
|
+
for (const name of entries) {
|
|
100
|
+
if (!name.startsWith(TEMP_PREFIX)) continue;
|
|
101
|
+
const full = path.join(root, name);
|
|
102
|
+
try {
|
|
103
|
+
const st = await stat(full);
|
|
104
|
+
if (!st.isDirectory()) continue;
|
|
105
|
+
if (now - st.mtimeMs < maxAgeMs) continue; // too fresh: could be a live run
|
|
106
|
+
await rm(full, { recursive: true, force: true });
|
|
107
|
+
removed.push(full);
|
|
108
|
+
} catch {
|
|
109
|
+
// stat/rm race or permission issue — skip this entry, keep sweeping.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return removed;
|
|
113
|
+
}
|