copperhead 0.6.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/dist/agent/loop.js +118 -16
- 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 +207 -27
- package/dist/agent/providers/claude-code.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/commands/create.js +470 -35
- package/dist/commands/create.js.map +1 -1
- package/dist/config.js +19 -0
- package/dist/config.js.map +1 -1
- package/dist/kicad/bootstrap.js +166 -0
- package/dist/kicad/bootstrap.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 +193 -22
- package/dist/memory/bom-table.js.map +1 -1
- package/dist/memory/drift.js +33 -11
- 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 +1 -1
- package/src/agent/loop.ts +136 -16
- package/src/agent/prompts.ts +2 -1
- package/src/agent/providers/claude-code.ts +207 -24
- 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 +17 -0
- package/src/commands/create.ts +528 -38
- package/src/config.ts +34 -0
- package/src/kicad/bootstrap.ts +181 -0
- package/src/kicad/spice.ts +399 -0
- package/src/kicad/symlib.ts +248 -0
- package/src/memory/bom-table.ts +191 -20
- package/src/memory/drift.ts +42 -11
- 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/dist/util/git.js
CHANGED
|
@@ -1,9 +1,43 @@
|
|
|
1
1
|
import { execa } from 'execa';
|
|
2
|
-
import { cp, mkdir, mkdtemp, rm } from 'node:fs/promises';
|
|
2
|
+
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { PreflightError } from './preflight.js';
|
|
7
|
+
/**
|
|
8
|
+
* Paths copperhead must keep out of `git add -A`. KiCad ≥9 writes a
|
|
9
|
+
* git-backed local-history directory (`.history/`, complete with its own nested
|
|
10
|
+
* `.git`) into the project the first time kicad-cli touches it. Left untracked,
|
|
11
|
+
* that nested repo has an unborn HEAD, so a plain `git add -A` in the parent
|
|
12
|
+
* aborts with `error: '.history/' does not have a commit checked out` (exit
|
|
13
|
+
* 128) — which fails the commit at the end of every KiCad-touching stage
|
|
14
|
+
* (schematic, layout, outputs). Ignoring it is both correct (local history is
|
|
15
|
+
* never a project artifact) and the fix for that abort. Kept as a list so other
|
|
16
|
+
* KiCad transients can join it if they surface.
|
|
17
|
+
*/
|
|
18
|
+
const GIT_ADD_EXCLUDES = ['.history/'];
|
|
19
|
+
/**
|
|
20
|
+
* Ensure the repo's root .gitignore lists each entry, appending only the
|
|
21
|
+
* missing ones. Idempotent and best-effort: a failure here must never block a
|
|
22
|
+
* commit, so it swallows its own errors. Run before any `git add -A` so a
|
|
23
|
+
* git-backed KiCad `.history/` (or similar nested repo) is skipped instead of
|
|
24
|
+
* aborting the add.
|
|
25
|
+
*/
|
|
26
|
+
export async function ensureIgnored(repo, entries) {
|
|
27
|
+
try {
|
|
28
|
+
const p = path.join(repo, '.gitignore');
|
|
29
|
+
const text = existsSync(p) ? await readFile(p, 'utf8') : '';
|
|
30
|
+
const present = new Set(text.split('\n').map((l) => l.trim()));
|
|
31
|
+
const missing = entries.filter((e) => !present.has(e));
|
|
32
|
+
if (!missing.length)
|
|
33
|
+
return;
|
|
34
|
+
const prefix = text.length && !text.endsWith('\n') ? '\n' : '';
|
|
35
|
+
await writeFile(p, text + prefix + missing.join('\n') + '\n', 'utf8');
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// best-effort: .gitignore maintenance must never be the thing that fails a run
|
|
39
|
+
}
|
|
40
|
+
}
|
|
7
41
|
async function git(repo, args) {
|
|
8
42
|
const { stdout } = await execa('git', args, { cwd: repo });
|
|
9
43
|
return stdout.trim();
|
|
@@ -130,6 +164,7 @@ export async function preserveFailedRun(repo, runId) {
|
|
|
130
164
|
try {
|
|
131
165
|
if (!(await isDirty(repo)))
|
|
132
166
|
return null;
|
|
167
|
+
await ensureIgnored(repo, GIT_ADD_EXCLUDES);
|
|
133
168
|
// Never leave the audit trail staged: a staged-but-not-in-HEAD path is
|
|
134
169
|
// deleted by restore()'s `reset --hard`, which silently defeats its
|
|
135
170
|
// `clean -e .copperhead/runs` protection (that flag only spares untracked
|
|
@@ -162,6 +197,7 @@ export async function uncommittedCount(repo) {
|
|
|
162
197
|
return status ? status.split('\n').length : 0;
|
|
163
198
|
}
|
|
164
199
|
export async function commitAll(repo, message) {
|
|
200
|
+
await ensureIgnored(repo, GIT_ADD_EXCLUDES);
|
|
165
201
|
await git(repo, ['add', '-A']);
|
|
166
202
|
await git(repo, ['commit', '-m', message]);
|
|
167
203
|
return git(repo, ['rev-parse', 'HEAD']);
|
package/dist/util/git.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"git.js","sourceRoot":"","sources":["../../src/util/git.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,OAAO,CAAC;AAC9B,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,EAAE,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"git.js","sourceRoot":"","sources":["../../src/util/git.ts"],"names":[],"mappings":"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"}
|
package/dist/util/preflight.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { statfs } from 'node:fs/promises';
|
|
1
2
|
/**
|
|
2
3
|
* A run-blocking environment failure. Distinct from a mid-run error: nothing
|
|
3
4
|
* has been written yet, so the message alone is the whole user experience.
|
|
@@ -21,4 +22,40 @@ export function formatPreflightFailure(reason, why, remedy) {
|
|
|
21
22
|
const steps = remedy.map((step, i) => ` ${i + 1}. ${step}`);
|
|
22
23
|
return [reason, '', `why it failed: ${why}`, 'to fix:', ...steps].join('\n');
|
|
23
24
|
}
|
|
25
|
+
/** Default minimum free space to start a run: 2 GiB. A create run emits gerbers,
|
|
26
|
+
* STEP, SVG renders and KiCad local history; 2 GiB is comfortably above a
|
|
27
|
+
* single board's output while still catching a nearly-full disk. */
|
|
28
|
+
export const DEFAULT_MIN_FREE_BYTES = 2 * 1024 * 1024 * 1024;
|
|
29
|
+
const gib = (n) => `${(n / 1024 / 1024 / 1024).toFixed(1)} GiB`;
|
|
30
|
+
/**
|
|
31
|
+
* Free bytes available to this (unprivileged) user on the filesystem holding
|
|
32
|
+
* `dir`, or null when the platform/Node build cannot report it — callers treat
|
|
33
|
+
* null as "unknown" and skip the check rather than blocking a legitimate run.
|
|
34
|
+
*/
|
|
35
|
+
export async function freeDiskBytes(dir) {
|
|
36
|
+
try {
|
|
37
|
+
const fs = await statfs(dir);
|
|
38
|
+
return fs.bavail * fs.bsize;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Refuse to start when free disk is below `minFreeBytes` (4.1). A long run can
|
|
46
|
+
* fill the disk mid-stage — gerbers/STEP/SVG plus unbounded KiCad local history
|
|
47
|
+
* — and then fail with an opaque `ENOSPC` after doing real, expensive work. A
|
|
48
|
+
* preflight fails fast with an actionable message instead. An unknown reading
|
|
49
|
+
* (unsupported platform) skips the check.
|
|
50
|
+
*/
|
|
51
|
+
export async function assertDiskSpace(dir, minFreeBytes = DEFAULT_MIN_FREE_BYTES) {
|
|
52
|
+
const free = await freeDiskBytes(dir);
|
|
53
|
+
if (free === null || free >= minFreeBytes)
|
|
54
|
+
return;
|
|
55
|
+
throw new PreflightError(`not enough free disk space to start (${gib(free)} available, ${gib(minFreeBytes)} required)`, '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', [
|
|
56
|
+
'free up space on the volume holding this repo',
|
|
57
|
+
'or lower the threshold with COPPERHEAD_MIN_FREE_MB (e.g. COPPERHEAD_MIN_FREE_MB=500)',
|
|
58
|
+
'then re-run',
|
|
59
|
+
]);
|
|
60
|
+
}
|
|
24
61
|
//# sourceMappingURL=preflight.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"preflight.js","sourceRoot":"","sources":["../../src/util/preflight.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,OAAO,cAAe,SAAQ,KAAK;IAE5B;IACA;IACA;IAHX,YACW,MAAc,EACd,GAAW,EACX,MAAgB;QAEzB,KAAK,CAAC,sBAAsB,CAAC,MAAM,EAAE,GAAG,EAAE,MAAM,CAAC,CAAC,CAAC;QAJ1C,WAAM,GAAN,MAAM,CAAQ;QACd,QAAG,GAAH,GAAG,CAAQ;QACX,WAAM,GAAN,MAAM,CAAU;QAGzB,IAAI,CAAC,IAAI,GAAG,gBAAgB,CAAC;IAC/B,CAAC;CACF;AAED,MAAM,UAAU,sBAAsB,CAAC,MAAc,EAAE,GAAW,EAAE,MAAgB;IAClF,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC;IAC7D,OAAO,CAAC,MAAM,EAAE,EAAE,EAAE,kBAAkB,GAAG,EAAE,EAAE,SAAS,EAAE,GAAG,KAAK,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC/E,CAAC"}
|
|
1
|
+
{"version":3,"file":"preflight.js","sourceRoot":"","sources":["../../src/util/preflight.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAE1C;;;;;;GAMG;AACH,MAAM,OAAO,cAAe,SAAQ,KAAK;IAE5B;IACA;IACA;IAHX,YACW,MAAc,EACd,GAAW,EACX,MAAgB;QAEzB,KAAK,CAAC,sBAAsB,CAAC,MAAM,EAAE,GAAG,EAAE,MAAM,CAAC,CAAC,CAAC;QAJ1C,WAAM,GAAN,MAAM,CAAQ;QACd,QAAG,GAAH,GAAG,CAAQ;QACX,WAAM,GAAN,MAAM,CAAU;QAGzB,IAAI,CAAC,IAAI,GAAG,gBAAgB,CAAC;IAC/B,CAAC;CACF;AAED,MAAM,UAAU,sBAAsB,CAAC,MAAc,EAAE,GAAW,EAAE,MAAgB;IAClF,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC;IAC7D,OAAO,CAAC,MAAM,EAAE,EAAE,EAAE,kBAAkB,GAAG,EAAE,EAAE,SAAS,EAAE,GAAG,KAAK,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC/E,CAAC;AAED;;qEAEqE;AACrE,MAAM,CAAC,MAAM,sBAAsB,GAAG,CAAC,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAE7D,MAAM,GAAG,GAAG,CAAC,CAAS,EAAU,EAAE,CAAC,GAAG,CAAC,CAAC,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC;AAEhF;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,GAAW;IAC7C,IAAI,CAAC;QACH,MAAM,EAAE,GAAG,MAAM,MAAM,CAAC,GAAG,CAAC,CAAC;QAC7B,OAAO,EAAE,CAAC,MAAM,GAAG,EAAE,CAAC,KAAK,CAAC;IAC9B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,GAAW,EAAE,YAAY,GAAG,sBAAsB;IACtF,MAAM,IAAI,GAAG,MAAM,aAAa,CAAC,GAAG,CAAC,CAAC;IACtC,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,IAAI,YAAY;QAAE,OAAO;IAClD,MAAM,IAAI,cAAc,CACtB,wCAAwC,GAAG,CAAC,IAAI,CAAC,eAAe,GAAG,CAAC,YAAY,CAAC,YAAY,EAC7F,2JAA2J,EAC3J;QACE,+CAA+C;QAC/C,sFAAsF;QACtF,aAAa;KACd,CACF,CAAC;AACJ,CAAC"}
|
package/dist/util/retry.js
CHANGED
|
@@ -3,6 +3,29 @@ export function isRateLimit(err) {
|
|
|
3
3
|
?? err?.statusCode;
|
|
4
4
|
return status === 429;
|
|
5
5
|
}
|
|
6
|
+
/**
|
|
7
|
+
* Detect a saved-login SESSION / USAGE limit (claude-code, codex), distinct from
|
|
8
|
+
* an HTTP 429 rate limit and from a code bug (2.4, I13). It is not a transient
|
|
9
|
+
* blip to back off on: it names its own reset time and clears only then. Because
|
|
10
|
+
* every completed turn is already in `.copperhead/llm-cache/`, re-running after
|
|
11
|
+
* the reset replays them at ~0 tokens and resumes in place — so the right
|
|
12
|
+
* handling is a schedulable pause with the reset time surfaced, not a bare
|
|
13
|
+
* "provider error". Returns the parsed reset time (verbatim) or null when the
|
|
14
|
+
* error is not a session/usage limit.
|
|
15
|
+
*/
|
|
16
|
+
export function sessionLimit(err) {
|
|
17
|
+
const status = err?.status
|
|
18
|
+
?? err?.statusCode;
|
|
19
|
+
if (status === 429)
|
|
20
|
+
return null; // a real rate limit: handled by backoff, not a pause
|
|
21
|
+
const msg = err?.message ?? '';
|
|
22
|
+
if (!/(session|usage|weekly)\s+limit|hit your .*limit|reached your usage|limit .*reset/i.test(msg)) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
// "resets 1:40pm", "resets at 1:40 pm", "reset at 13:40" — capture the clock text.
|
|
26
|
+
const reset = msg.match(/reset[s]?(?:\s+at)?\s+([0-9]{1,2}(?::[0-9]{2})?\s*(?:am|pm)?)/i);
|
|
27
|
+
return { resetsAt: reset?.[1]?.trim() ?? null };
|
|
28
|
+
}
|
|
6
29
|
/** Exponential backoff ×N for rate limits (SPEC §4.5). */
|
|
7
30
|
export async function withRetry(fn, opts = {}) {
|
|
8
31
|
const retries = opts.retries ?? 3;
|
package/dist/util/retry.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"retry.js","sourceRoot":"","sources":["../../src/util/retry.ts"],"names":[],"mappings":"AAQA,MAAM,UAAU,WAAW,CAAC,GAAY;IACtC,MAAM,MAAM,GAAI,GAAgD,EAAE,MAAM;WAClE,GAA+B,EAAE,UAAU,CAAC;IAClD,OAAO,MAAM,KAAK,GAAG,CAAC;AACxB,CAAC;AAED,0DAA0D;AAC1D,MAAM,CAAC,KAAK,UAAU,SAAS,CAAI,EAAoB,EAAE,OAAkB,EAAE;IAC3E,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,IAAI,CAAC,CAAC;IAClC,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,IAAI,IAAI,CAAC;IACnC,MAAM,WAAW,GAAG,IAAI,CAAC,WAAW,IAAI,WAAW,CAAC;IACpD,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,IAAI,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,IAAI,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC;IAC5E,IAAI,OAAgB,CAAC;IACrB,KAAK,IAAI,OAAO,GAAG,CAAC,EAAE,OAAO,IAAI,OAAO,EAAE,OAAO,EAAE,EAAE,CAAC;QACpD,IAAI,CAAC;YACH,OAAO,MAAM,EAAE,EAAE,CAAC;QACpB,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,OAAO,GAAG,GAAG,CAAC;YACd,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,IAAI,OAAO,KAAK,OAAO;gBAAE,MAAM,GAAG,CAAC;YACxD,IAAI,CAAC,OAAO,EAAE,CAAC,OAAO,GAAG,CAAC,EAAE,GAAG,CAAC,CAAC;YACjC,MAAM,KAAK,CAAC,MAAM,GAAG,CAAC,IAAI,OAAO,CAAC,CAAC;QACrC,CAAC;IACH,CAAC;IACD,MAAM,OAAO,CAAC;AAChB,CAAC"}
|
|
1
|
+
{"version":3,"file":"retry.js","sourceRoot":"","sources":["../../src/util/retry.ts"],"names":[],"mappings":"AAQA,MAAM,UAAU,WAAW,CAAC,GAAY;IACtC,MAAM,MAAM,GAAI,GAAgD,EAAE,MAAM;WAClE,GAA+B,EAAE,UAAU,CAAC;IAClD,OAAO,MAAM,KAAK,GAAG,CAAC;AACxB,CAAC;AAQD;;;;;;;;;GASG;AACH,MAAM,UAAU,YAAY,CAAC,GAAY;IACvC,MAAM,MAAM,GAAI,GAAgD,EAAE,MAAM;WAClE,GAA+B,EAAE,UAAU,CAAC;IAClD,IAAI,MAAM,KAAK,GAAG;QAAE,OAAO,IAAI,CAAC,CAAC,qDAAqD;IACtF,MAAM,GAAG,GAAI,GAAa,EAAE,OAAO,IAAI,EAAE,CAAC;IAC1C,IAAI,CAAC,mFAAmF,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC;QACnG,OAAO,IAAI,CAAC;IACd,CAAC;IACD,mFAAmF;IACnF,MAAM,KAAK,GAAG,GAAG,CAAC,KAAK,CAAC,gEAAgE,CAAC,CAAC;IAC1F,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,IAAI,EAAE,CAAC;AAClD,CAAC;AAED,0DAA0D;AAC1D,MAAM,CAAC,KAAK,UAAU,SAAS,CAAI,EAAoB,EAAE,OAAkB,EAAE;IAC3E,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,IAAI,CAAC,CAAC;IAClC,MAAM,MAAM,GAAG,IAAI,CAAC,MAAM,IAAI,IAAI,CAAC;IACnC,MAAM,WAAW,GAAG,IAAI,CAAC,WAAW,IAAI,WAAW,CAAC;IACpD,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,IAAI,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,IAAI,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,UAAU,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC;IAC5E,IAAI,OAAgB,CAAC;IACrB,KAAK,IAAI,OAAO,GAAG,CAAC,EAAE,OAAO,IAAI,OAAO,EAAE,OAAO,EAAE,EAAE,CAAC;QACpD,IAAI,CAAC;YACH,OAAO,MAAM,EAAE,EAAE,CAAC;QACpB,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACb,OAAO,GAAG,GAAG,CAAC;YACd,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,IAAI,OAAO,KAAK,OAAO;gBAAE,MAAM,GAAG,CAAC;YACxD,IAAI,CAAC,OAAO,EAAE,CAAC,OAAO,GAAG,CAAC,EAAE,GAAG,CAAC,CAAC;YACjC,MAAM,KAAK,CAAC,MAAM,GAAG,CAAC,IAAI,OAAO,CAAC,CAAC;QACrC,CAAC;IACH,CAAC;IACD,MAAM,OAAO,CAAC;AAChB,CAAC"}
|
package/dist/util/tmp.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { readdir, stat, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
/** Newest local-history entries to keep when capping `.history/` (see
|
|
5
|
+
* pruneHistoryDir). Enough to preserve a useful recovery window, bounded enough
|
|
6
|
+
* that the dir cannot grow without limit across a long run. */
|
|
7
|
+
export const DEFAULT_HISTORY_KEEP = 200;
|
|
8
|
+
/**
|
|
9
|
+
* Cap the growth of a repo's `.history/` directory (4.1). KiCad (and editor
|
|
10
|
+
* local-history) rewrite a snapshot on every project touch, so across a long
|
|
11
|
+
* run `.history/` grows without bound and was a contributor to the disk-fill
|
|
12
|
+
* halt (I8). It is gitignored, so its contents are disposable: keep the newest
|
|
13
|
+
* `keepNewest` files by mtime and remove the rest. Recursive (local history
|
|
14
|
+
* mirrors the workspace tree), best-effort (every error is swallowed — pruning
|
|
15
|
+
* housekeeping must never fail a run), and a no-op when the dir is absent or
|
|
16
|
+
* already under the cap. Returns the number of files removed.
|
|
17
|
+
*/
|
|
18
|
+
export async function pruneHistoryDir(repoRoot, keepNewest = DEFAULT_HISTORY_KEEP) {
|
|
19
|
+
const root = path.join(repoRoot, '.history');
|
|
20
|
+
const files = [];
|
|
21
|
+
const walk = async (dir) => {
|
|
22
|
+
let entries;
|
|
23
|
+
try {
|
|
24
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return; // unreadable dir — skip it
|
|
28
|
+
}
|
|
29
|
+
for (const e of entries) {
|
|
30
|
+
const full = path.join(dir, e.name);
|
|
31
|
+
if (e.isDirectory()) {
|
|
32
|
+
await walk(full);
|
|
33
|
+
}
|
|
34
|
+
else if (e.isFile()) {
|
|
35
|
+
try {
|
|
36
|
+
const st = await stat(full);
|
|
37
|
+
files.push({ full, mtimeMs: st.mtimeMs });
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// stat race — skip this file
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
await walk(root);
|
|
46
|
+
if (files.length <= keepNewest)
|
|
47
|
+
return 0;
|
|
48
|
+
files.sort((a, b) => b.mtimeMs - a.mtimeMs); // newest first
|
|
49
|
+
let removed = 0;
|
|
50
|
+
for (const f of files.slice(keepNewest)) {
|
|
51
|
+
try {
|
|
52
|
+
await rm(f.full, { force: true });
|
|
53
|
+
removed++;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// permission/race — leave it, keep pruning the rest
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return removed;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Every scratch dir copperhead makes under the OS temp dir shares this prefix:
|
|
63
|
+
* kicad-cli ERC/DRC (`copperhead-`), the KiCad edit probe (`copperhead-validate-`),
|
|
64
|
+
* the failed-run backup (`copperhead-runs-`), and the provider working dirs
|
|
65
|
+
* (`copperhead-cc-`, `copperhead-codex-`). Each site removes its own dir in a
|
|
66
|
+
* `finally`, but a watchdog SIGKILL of the process tree or a hard abort skips
|
|
67
|
+
* that cleanup, so stale dirs accumulate across runs and can eventually fill the
|
|
68
|
+
* disk (I8). A prefix match lets one sweep reclaim all of them.
|
|
69
|
+
*/
|
|
70
|
+
export const TEMP_PREFIX = 'copperhead-';
|
|
71
|
+
/** Default staleness cutoff for the startup sweep: 2h. Safe even for multi-hour
|
|
72
|
+
* runs (10-min turns × per-stage retries × 8 stages): a live run's only
|
|
73
|
+
* long-lived scratch dir is the provider's reused cwd, which is `utimes`-touched
|
|
74
|
+
* every turn (ClaudeCodeProvider.ensureCwd), so its mtime never goes stale while
|
|
75
|
+
* the process is alive; per-call kicad-cli dirs are removed within a turn. A dir
|
|
76
|
+
* older than this therefore belongs to a dead run, and the window is short enough
|
|
77
|
+
* that such a leak is reclaimed on the very next invocation. */
|
|
78
|
+
export const DEFAULT_STALE_MS = 2 * 60 * 60 * 1000;
|
|
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, maxAgeMs = DEFAULT_STALE_MS) {
|
|
91
|
+
const root = tmpdir();
|
|
92
|
+
const removed = [];
|
|
93
|
+
let entries;
|
|
94
|
+
try {
|
|
95
|
+
entries = await readdir(root);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return removed; // no temp dir / not readable — nothing to sweep
|
|
99
|
+
}
|
|
100
|
+
for (const name of entries) {
|
|
101
|
+
if (!name.startsWith(TEMP_PREFIX))
|
|
102
|
+
continue;
|
|
103
|
+
const full = path.join(root, name);
|
|
104
|
+
try {
|
|
105
|
+
const st = await stat(full);
|
|
106
|
+
if (!st.isDirectory())
|
|
107
|
+
continue;
|
|
108
|
+
if (now - st.mtimeMs < maxAgeMs)
|
|
109
|
+
continue; // too fresh: could be a live run
|
|
110
|
+
await rm(full, { recursive: true, force: true });
|
|
111
|
+
removed.push(full);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// stat/rm race or permission issue — skip this entry, keep sweeping.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return removed;
|
|
118
|
+
}
|
|
119
|
+
//# sourceMappingURL=tmp.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tmp.js","sourceRoot":"","sources":["../../src/util/tmp.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "copperhead",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Cursor for circuit boards: an AI agent that designs, documents, and validates real PCBs on KiCad repositories",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Animesh Chouhan <animeshchouhan@outlook.com>",
|
package/src/agent/loop.ts
CHANGED
|
@@ -3,15 +3,17 @@ import { readFile, writeFile } from 'node:fs/promises';
|
|
|
3
3
|
import { execa } from 'execa';
|
|
4
4
|
import type { Msg, Provider, Turn } from './types.js';
|
|
5
5
|
import { availableTools, dispatchTool, type RunContext } from './tools.js';
|
|
6
|
+
import { CachingProvider } from './response-cache.js';
|
|
7
|
+
import { withTimeout, TurnTimeoutError } from './recovery.js';
|
|
6
8
|
import { buildSystemPrompt } from './prompts.js';
|
|
7
9
|
import { loadConstraints, reopenDeferredAffects } from '../memory/constraints.js';
|
|
8
|
-
import { loadConfig, type CopperheadConfig } from '../config.js';
|
|
10
|
+
import { loadConfig, CONFIG_DIR, type CopperheadConfig } from '../config.js';
|
|
9
11
|
import { Transcript, type ExitPath, type RunStats } from './transcript.js';
|
|
10
12
|
import { collectRunMeta, renderCliHeader, type RunMeta, type RunMetaInput } from './runmeta.js';
|
|
11
13
|
import { plainRenderer, fmtDuration, fmtTokens, type ProgressRenderer } from './render.js';
|
|
12
14
|
import { ObligationsLedger } from './ledger.js';
|
|
13
15
|
import { gitPreflight, isDirty, snapshot, restore, commitAll, changedFiles, preserveFailedRun } from '../util/git.js';
|
|
14
|
-
import { withRetry, isRateLimit } from '../util/retry.js';
|
|
16
|
+
import { withRetry, isRateLimit, sessionLimit } from '../util/retry.js';
|
|
15
17
|
import { openspecArchive } from '../openspec/cli.js';
|
|
16
18
|
import { existsSync } from 'node:fs';
|
|
17
19
|
import { OpenAIProvider } from './providers/openai.js';
|
|
@@ -63,9 +65,14 @@ export interface RunResult {
|
|
|
63
65
|
transcriptDir: string;
|
|
64
66
|
filesTouched: string[];
|
|
65
67
|
commit: string | null;
|
|
68
|
+
/** Cost/telemetry for this run. Surfaced by the create pipeline's per-stage
|
|
69
|
+
* cost table (5.2) so the expensive stages are obvious across runs. */
|
|
70
|
+
stats: RunStats;
|
|
71
|
+
/** Number of turns served from the on-disk response cache (5.2). */
|
|
72
|
+
cacheHits: number;
|
|
66
73
|
}
|
|
67
74
|
|
|
68
|
-
export async function makeProvider(model: string): Promise<Provider> {
|
|
75
|
+
export async function makeProvider(model: string, sessionResume = false): Promise<Provider> {
|
|
69
76
|
if (model === 'codex' || model.startsWith('codex:')) {
|
|
70
77
|
const codexModel = model.startsWith('codex:') ? model.slice('codex:'.length) : undefined;
|
|
71
78
|
if (codexModel === '') throw new Error('codex model override cannot be empty; use "codex" or "codex:<model-id>"');
|
|
@@ -91,7 +98,7 @@ export async function makeProvider(model: string): Promise<Provider> {
|
|
|
91
98
|
if (claudeCodeModel === '') {
|
|
92
99
|
throw new Error('claude-code model override cannot be empty; use "claude-code" or "claude-code:<model-id>"');
|
|
93
100
|
}
|
|
94
|
-
return new ClaudeCodeProvider(claudeCodeModel);
|
|
101
|
+
return new ClaudeCodeProvider(claudeCodeModel, undefined, undefined, sessionResume);
|
|
95
102
|
}
|
|
96
103
|
if (model === 'claude' || model.startsWith('claude')) {
|
|
97
104
|
return new AnthropicProvider(model === 'claude' ? undefined : model);
|
|
@@ -198,8 +205,23 @@ async function runWithMemory(
|
|
|
198
205
|
finishRequest: null,
|
|
199
206
|
};
|
|
200
207
|
|
|
201
|
-
|
|
208
|
+
// Session resume for claude-code (1.1) is only correct when the response cache
|
|
209
|
+
// is off: the cache replays turns a resumed session never saw. So enable it
|
|
210
|
+
// only when the env flag is set AND config.llmCache is disabled — the same
|
|
211
|
+
// condition under which we skip the CachingProvider wrap below.
|
|
212
|
+
const sessionResume = process.env.COPPERHEAD_CC_SESSION_RESUME === '1' && !config.llmCache;
|
|
213
|
+
let provider = opts.provider ?? (await makeProvider(opts.model, sessionResume));
|
|
214
|
+
// Cache every turn's response so a retried/restarted stage replays what it
|
|
215
|
+
// already paid for instead of re-calling the model (repo-scoped, cross-run).
|
|
216
|
+
// Skip an injected provider (tests drive scripted providers directly).
|
|
217
|
+
if (config.llmCache && !opts.provider) {
|
|
218
|
+
provider = new CachingProvider(provider, path.join(repoRoot, CONFIG_DIR, 'llm-cache'), log, opts.model);
|
|
219
|
+
}
|
|
202
220
|
providers.add(provider);
|
|
221
|
+
// Held separately from `provider` (which is reassigned on failover) so the
|
|
222
|
+
// final cache-hit count survives a mid-run provider switch (5.2).
|
|
223
|
+
const cachingProvider = provider instanceof CachingProvider ? provider : null;
|
|
224
|
+
const cacheHits = (): number => cachingProvider?.cacheHits ?? 0;
|
|
203
225
|
|
|
204
226
|
// Deterministic, LLM-free metadata block: collected once, rendered onto all
|
|
205
227
|
// three surfaces (run-start event, summary ## Environment, CLI header) so
|
|
@@ -277,6 +299,8 @@ async function runWithMemory(
|
|
|
277
299
|
const perTurn: { turn: number; in: number; out: number }[] = [];
|
|
278
300
|
let plan: string | null = null;
|
|
279
301
|
let nudges = 0;
|
|
302
|
+
let turnTimeouts = 0;
|
|
303
|
+
const maxTurnTimeouts = 3;
|
|
280
304
|
|
|
281
305
|
const stats = (exitPath: ExitPath): RunStats => ({
|
|
282
306
|
exitPath,
|
|
@@ -356,6 +380,8 @@ async function runWithMemory(
|
|
|
356
380
|
transcriptDir: transcript.dir,
|
|
357
381
|
filesTouched: [],
|
|
358
382
|
commit: null,
|
|
383
|
+
stats: runStats,
|
|
384
|
+
cacheHits: cacheHits(),
|
|
359
385
|
};
|
|
360
386
|
};
|
|
361
387
|
|
|
@@ -388,15 +414,64 @@ async function runWithMemory(
|
|
|
388
414
|
await transcript.event('budget-extended', { extraTurns: extra, budget, ...exhaustStats });
|
|
389
415
|
log(`turn budget extended by ${extra} (now ${budget})`);
|
|
390
416
|
}
|
|
417
|
+
// Advertise EVERY tool each turn; dispatchTool enforces the edit-unlock gate
|
|
418
|
+
// live at call time. Hiding locked edit tools from the turn catalog meant a
|
|
419
|
+
// model that unlocked (validate_change) and edited in the SAME reply had its
|
|
420
|
+
// edit silently dropped in parsing — the call named a tool the turn had not
|
|
421
|
+
// advertised, so it was treated as prose, executed nothing, and returned no
|
|
422
|
+
// error. The model then "verified" against an unchanged file (an empty
|
|
423
|
+
// schematic even passes ERC) and finished believing it had succeeded.
|
|
424
|
+
// Structural lock (SPEC.md §1.3 invariant 1): the edit tools stay OUT of the
|
|
425
|
+
// advertised list until a proposal validates (`editsUnlocked`), so the model
|
|
426
|
+
// is gated by omission, not by prompt text. `dispatchTool` re-checks the same
|
|
427
|
+
// `availableTools(ctx)` live, so this is defense in depth. A premature edit is
|
|
428
|
+
// simply not offered; once `validate_change` unlocks, the next turn advertises
|
|
429
|
+
// the edit tools. (Earlier this advertised every tool to let a same-turn
|
|
430
|
+
// propose→validate→edit batch through, but that traded the spec's structural
|
|
431
|
+
// guarantee for one saved turn — not worth it.)
|
|
391
432
|
const tools = availableTools(ctx).map((t) => t.schema);
|
|
392
433
|
r.turnStart(turn + 1, maxTurns, tokensIn, tokensOut);
|
|
393
434
|
r.status('thinking');
|
|
394
435
|
let res: Turn;
|
|
436
|
+
// Liveness heartbeat (5.1): a large-output turn can legitimately run several
|
|
437
|
+
// minutes, which is otherwise indistinguishable from a hung subprocess until
|
|
438
|
+
// the watchdog fires. Emit a periodic elapsed/streamed signal so an operator
|
|
439
|
+
// can tell the two apart. Fires only after the first interval, so quick turns
|
|
440
|
+
// stay silent; `unref` keeps it from holding the event loop open.
|
|
441
|
+
const turnStartMs = Date.now();
|
|
442
|
+
let streamedChars = 0;
|
|
443
|
+
const heartbeat =
|
|
444
|
+
config.heartbeatMs > 0
|
|
445
|
+
? setInterval(
|
|
446
|
+
() => r.heartbeat({ elapsedMs: Date.now() - turnStartMs, streamedChars }),
|
|
447
|
+
config.heartbeatMs,
|
|
448
|
+
)
|
|
449
|
+
: null;
|
|
450
|
+
heartbeat?.unref?.();
|
|
395
451
|
try {
|
|
396
|
-
res = await withRetry(
|
|
397
|
-
|
|
398
|
-
|
|
452
|
+
res = await withRetry(
|
|
453
|
+
() =>
|
|
454
|
+
withTimeout(
|
|
455
|
+
() => provider.chat(messages, tools, { onStream: (chars) => (streamedChars = chars) }),
|
|
456
|
+
config.turnTimeoutMs,
|
|
457
|
+
() => provider.close?.(),
|
|
458
|
+
),
|
|
459
|
+
{ onRetry: (attempt) => log(`rate limited; retry ${attempt}`) },
|
|
460
|
+
);
|
|
399
461
|
} catch (err) {
|
|
462
|
+
if (err instanceof TurnTimeoutError) {
|
|
463
|
+
// A hung provider turn: the watchdog aborted the in-flight call and tore
|
|
464
|
+
// down its subprocess. Retry the same turn a bounded number of times
|
|
465
|
+
// before giving up, so a transient hang self-heals instead of stalling
|
|
466
|
+
// the run forever.
|
|
467
|
+
if (turnTimeouts++ < maxTurnTimeouts) {
|
|
468
|
+
log(`turn exceeded ${config.turnTimeoutMs}ms; aborted the hung call and retrying (${turnTimeouts}/${maxTurnTimeouts})`);
|
|
469
|
+
await transcript.event('turn-timeout', { ms: config.turnTimeoutMs, attempt: turnTimeouts });
|
|
470
|
+
turn--;
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
return fail(`provider turns timed out ${turnTimeouts}× (>${config.turnTimeoutMs}ms each)`, 'provider-error');
|
|
474
|
+
}
|
|
400
475
|
if (isRateLimit(err)) {
|
|
401
476
|
const fallback = otherProvider(provider);
|
|
402
477
|
if (fallback) {
|
|
@@ -408,11 +483,34 @@ async function runWithMemory(
|
|
|
408
483
|
continue;
|
|
409
484
|
}
|
|
410
485
|
}
|
|
486
|
+
// A saved-login session/usage limit is not a code bug and not a 429 (2.4,
|
|
487
|
+
// I13): it names its own reset time and clears only then, and every turn
|
|
488
|
+
// so far is already in the llm-cache — so re-running after the reset
|
|
489
|
+
// replays them at ~0 tokens and resumes in place. Surface it as its own
|
|
490
|
+
// exit path with the reset time and the resume instruction, rather than a
|
|
491
|
+
// bare "provider error" the operator would read as a failure to debug.
|
|
492
|
+
const limit = sessionLimit(err);
|
|
493
|
+
if (limit) {
|
|
494
|
+
const when = limit.resetsAt ? ` (resets ${limit.resetsAt})` : '';
|
|
495
|
+
await transcript.event('session-limit', { resetsAt: limit.resetsAt, provider: provider.name });
|
|
496
|
+
return fail(
|
|
497
|
+
`${provider.name} session/usage limit reached${when} — this is a schedulable pause, not a bug. ` +
|
|
498
|
+
`Wait for the reset, then re-run the same command: completed turns replay from the cache at ~0 tokens and the run resumes where it left off.`,
|
|
499
|
+
'session-limit',
|
|
500
|
+
);
|
|
501
|
+
}
|
|
411
502
|
return fail(`provider error: ${(err as Error).message}`, 'provider-error');
|
|
412
503
|
} finally {
|
|
504
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
413
505
|
r.status(null);
|
|
414
506
|
}
|
|
415
507
|
turnsUsed = turn + 1;
|
|
508
|
+
// A productive turn resets the timeout budget: maxTurnTimeouts is meant to
|
|
509
|
+
// catch a turn that is genuinely, repeatedly stuck — not to cap the total
|
|
510
|
+
// number of slow-but-recoverable turns across a whole stage. Without this a
|
|
511
|
+
// long stage that merely has a few independent slow turns accumulates
|
|
512
|
+
// timeouts and hard-fails even though every one of them recovered.
|
|
513
|
+
turnTimeouts = 0;
|
|
416
514
|
tokensIn += res.usage.inputTokens;
|
|
417
515
|
tokensOut += res.usage.outputTokens;
|
|
418
516
|
perTurn.push({ turn: turn + 1, in: res.usage.inputTokens, out: res.usage.outputTokens });
|
|
@@ -432,7 +530,9 @@ async function runWithMemory(
|
|
|
432
530
|
if (nudges++ >= 2) return fail('model stopped calling tools without finishing', 'stalled');
|
|
433
531
|
messages.push({
|
|
434
532
|
role: 'user',
|
|
435
|
-
|
|
533
|
+
// A near-miss malformed tool call (#I10) gets a specific steer to re-emit
|
|
534
|
+
// it; an ordinary tool-less turn gets the generic continue prompt.
|
|
535
|
+
content: res.nudge ?? 'Continue using tools, or call finish({outcome, summary}) to end the run.',
|
|
436
536
|
});
|
|
437
537
|
continue;
|
|
438
538
|
}
|
|
@@ -502,6 +602,8 @@ async function runWithMemory(
|
|
|
502
602
|
transcriptDir: transcript.dir,
|
|
503
603
|
filesTouched: [],
|
|
504
604
|
commit: null,
|
|
605
|
+
stats: runStats,
|
|
606
|
+
cacheHits: cacheHits(),
|
|
505
607
|
};
|
|
506
608
|
}
|
|
507
609
|
|
|
@@ -547,16 +649,32 @@ async function runWithMemory(
|
|
|
547
649
|
transcriptDir: transcript.dir,
|
|
548
650
|
filesTouched: files,
|
|
549
651
|
commit: null,
|
|
652
|
+
stats: runStats,
|
|
653
|
+
cacheHits: cacheHits(),
|
|
550
654
|
};
|
|
551
655
|
}
|
|
552
656
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
657
|
+
// Bookkeeping must never cost the verified design its commit (2.1): the
|
|
658
|
+
// KiCad work passed its ERC/DRC gates, so a failure appending the changelog
|
|
659
|
+
// (a plain CHANGELOG.md read+write) is a warning, not a rollback. It stays
|
|
660
|
+
// before commitAll so, on the normal path, the entry lands in the run's
|
|
661
|
+
// single commit and a zero-edit "done" run still has something to commit;
|
|
662
|
+
// if it throws, the design is committed without a changelog line rather
|
|
663
|
+
// than sent through fail()'s rollback. The other bookkeeping — the openspec
|
|
664
|
+
// archive — is already post-commit and non-fatal below.
|
|
665
|
+
try {
|
|
666
|
+
await appendChangelog(repoRoot, config, {
|
|
667
|
+
changeId: ctx.changeId,
|
|
668
|
+
request: opts.request,
|
|
669
|
+
files,
|
|
670
|
+
verification,
|
|
671
|
+
});
|
|
672
|
+
ctx.ledger.clear('changelog');
|
|
673
|
+
} catch (err) {
|
|
674
|
+
const message = (err as Error).message;
|
|
675
|
+
log(`warning: changelog append failed (${message}); committing the verified design without a changelog entry`);
|
|
676
|
+
await transcript.event('changelog-append-failed', { error: message });
|
|
677
|
+
}
|
|
560
678
|
|
|
561
679
|
const commitMsg = `copperhead: ${opts.request}\n\n${summary}\n\nVerification: ${verification}`;
|
|
562
680
|
// A git failure here (e.g. `git add -A` exiting 128 on an embedded repo)
|
|
@@ -619,6 +737,8 @@ async function runWithMemory(
|
|
|
619
737
|
transcriptDir: transcript.dir,
|
|
620
738
|
filesTouched: files,
|
|
621
739
|
commit,
|
|
740
|
+
stats: runStats,
|
|
741
|
+
cacheHits: cacheHits(),
|
|
622
742
|
};
|
|
623
743
|
}
|
|
624
744
|
}
|
package/src/agent/prompts.ts
CHANGED
|
@@ -23,7 +23,8 @@ const WORKFLOW = `Workflow for every run:
|
|
|
23
23
|
6. Record every non-trivial decision with record_decision, and every stated/assumed/discovered constraint with record_constraint.
|
|
24
24
|
7. Call finish with outcome "done" when everything is verified, or outcome "refuse" (citing the violated budget/constraint) if the request should not be done. finish will list any unmet obligations; resolve them and call it again.
|
|
25
25
|
|
|
26
|
-
Turns are the scarce resource, not tool calls: the run has a hard turn budget, and every tool call in one reply executes in the same turn. When calls are independent — multiple record_constraint or resolve_affected calls (use resolutions: [...] to clear a backlog in one call), several read_file calls — issue them together in a single reply instead of one per turn
|
|
26
|
+
Turns are the scarce resource, not tool calls: the run has a hard turn budget, and every tool call in one reply executes in the same turn. When calls are independent — multiple record_constraint or resolve_affected calls (use resolutions: [...] to clear a backlog in one call), several read_file calls — issue them together in a single reply instead of one per turn.
|
|
27
|
+
Always send a populated \`args\` object that matches the tool's JSON Schema (e.g. read_file needs {"path": "..."}). Never open a stage with an empty-args call to probe a tool — it only returns an error and burns a whole turn.`;
|
|
27
28
|
|
|
28
29
|
export async function buildSystemPrompt(
|
|
29
30
|
repoRoot: string,
|