copperhead 0.6.0 → 0.8.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 +36 -4
- package/dist/agent/animate.js +76 -0
- package/dist/agent/animate.js.map +1 -0
- package/dist/agent/box.js +89 -0
- package/dist/agent/box.js.map +1 -0
- package/dist/agent/dock-renderer.js +173 -0
- package/dist/agent/dock-renderer.js.map +1 -0
- package/dist/agent/logo.js +21 -0
- package/dist/agent/logo.js.map +1 -0
- package/dist/agent/loop.js +130 -18
- 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 +85 -116
- package/dist/agent/providers/claude-code.js.map +1 -1
- package/dist/agent/providers/cursor.js +317 -0
- package/dist/agent/providers/cursor.js.map +1 -0
- package/dist/agent/providers/tool-protocol.js +205 -0
- package/dist/agent/providers/tool-protocol.js.map +1 -0
- package/dist/agent/recovery.js +148 -0
- package/dist/agent/recovery.js.map +1 -0
- package/dist/agent/render.js +44 -12
- 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/runmeta.js +4 -5
- package/dist/agent/runmeta.js.map +1 -1
- package/dist/agent/theme.js +84 -0
- package/dist/agent/theme.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 +134 -13
- package/dist/cli.js.map +1 -1
- package/dist/commands/create.js +482 -38
- package/dist/commands/create.js.map +1 -1
- package/dist/commands/demo.js +146 -0
- package/dist/commands/demo.js.map +1 -0
- package/dist/commands/doctor.js +240 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/repl-inspect.js +342 -0
- package/dist/commands/repl-inspect.js.map +1 -0
- package/dist/commands/repl.js +618 -0
- package/dist/commands/repl.js.map +1 -0
- package/dist/config.js +24 -2
- 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/cli.js +126 -6
- package/dist/kicad/cli.js.map +1 -1
- 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/cli-args.js +35 -0
- package/dist/util/cli-args.js.map +1 -0
- package/dist/util/dock.js +155 -0
- package/dist/util/dock.js.map +1 -0
- package/dist/util/git.js +165 -4
- package/dist/util/git.js.map +1 -1
- package/dist/util/live-prompt.js +542 -0
- package/dist/util/live-prompt.js.map +1 -0
- package/dist/util/paths.js +9 -0
- package/dist/util/paths.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/select.js +172 -0
- package/dist/util/select.js.map +1 -0
- package/dist/util/tmp.js +119 -0
- package/dist/util/tmp.js.map +1 -0
- package/package.json +3 -2
- package/src/agent/animate.ts +90 -0
- package/src/agent/box.ts +99 -0
- package/src/agent/dock-renderer.ts +181 -0
- package/src/agent/logo.ts +23 -0
- package/src/agent/loop.ts +148 -18
- package/src/agent/prompts.ts +2 -1
- package/src/agent/providers/claude-code.ts +91 -122
- package/src/agent/providers/cursor.ts +364 -0
- package/src/agent/providers/tool-protocol.ts +212 -0
- package/src/agent/recovery.ts +162 -0
- package/src/agent/render.ts +56 -12
- package/src/agent/response-cache.ts +80 -0
- package/src/agent/runmeta.ts +6 -7
- package/src/agent/theme.ts +91 -0
- package/src/agent/tools.ts +62 -4
- package/src/agent/transcript.ts +1 -0
- package/src/agent/types.ts +17 -0
- package/src/cli.ts +139 -15
- package/src/commands/create.ts +581 -40
- package/src/commands/demo.ts +184 -0
- package/src/commands/doctor.ts +289 -0
- package/src/commands/repl-inspect.ts +353 -0
- package/src/commands/repl.ts +685 -0
- package/src/config.ts +40 -3
- package/src/kicad/bootstrap.ts +181 -0
- package/src/kicad/cli.ts +132 -7
- package/src/kicad/spice.ts +399 -0
- package/src/kicad/symlib.ts +248 -0
- package/src/layout/claude-ui-layout.md +72 -0
- package/src/layout/repl-ui-layout.md +139 -0
- package/src/memory/bom-table.ts +191 -20
- package/src/memory/drift.ts +42 -11
- package/src/util/cli-args.ts +42 -0
- package/src/util/dock.ts +161 -0
- package/src/util/git.ts +176 -4
- package/src/util/live-prompt.ts +595 -0
- package/src/util/paths.ts +10 -0
- package/src/util/preflight.ts +44 -0
- package/src/util/retry.ts +29 -0
- package/src/util/select.ts +192 -0
- package/src/util/tmp.ts +113 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REPL-session renderer: the single owner of the bottom dock during agent
|
|
3
|
+
* turns. Durable output (tool lines, turn markers, the outcome) flows into
|
|
4
|
+
* the content region through `emit`, which the REPL also records as
|
|
5
|
+
* scrollable history; the live observability line (spinner, turn, tokens,
|
|
6
|
+
* elapsed, busy text) is painted inside the dock, pinned to the bottom of
|
|
7
|
+
* the screen no matter how much output scrolls above it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { rule, statusBar } from './box.js';
|
|
11
|
+
import { copper, dim, styleOutcome, toolLine, warn } from './theme.js';
|
|
12
|
+
import { fmtDuration, fmtTokens, turnMarker, type ProgressRenderer } from './render.js';
|
|
13
|
+
import type { TerminalDock } from '../util/dock.js';
|
|
14
|
+
|
|
15
|
+
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
16
|
+
|
|
17
|
+
/** Claude Code-style working words, board-shop edition. One per turn. */
|
|
18
|
+
const WORKING = [
|
|
19
|
+
'Routing',
|
|
20
|
+
'Etching',
|
|
21
|
+
'Reflowing',
|
|
22
|
+
'Soldering',
|
|
23
|
+
'Drilling',
|
|
24
|
+
'Plating',
|
|
25
|
+
'Probing',
|
|
26
|
+
'Fluxing',
|
|
27
|
+
'Tinning',
|
|
28
|
+
'Laminating',
|
|
29
|
+
'Silkscreening',
|
|
30
|
+
'Panelizing',
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/** Fixed word-slot width: longest word plus the static dots. */
|
|
34
|
+
const WORD_SLOT = Math.max(...WORKING.map((w) => w.length)) + 3;
|
|
35
|
+
|
|
36
|
+
export class DockRenderer implements ProgressRenderer {
|
|
37
|
+
private turn = 0;
|
|
38
|
+
private maxTurns = 0;
|
|
39
|
+
private tokensIn = 0;
|
|
40
|
+
private tokensOut = 0;
|
|
41
|
+
private streamedChars = 0;
|
|
42
|
+
private busy: string | null = null;
|
|
43
|
+
private frame = 0;
|
|
44
|
+
private runSeed = 0;
|
|
45
|
+
private startMs = Date.now();
|
|
46
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
47
|
+
|
|
48
|
+
constructor(
|
|
49
|
+
private readonly dock: TerminalDock,
|
|
50
|
+
/** Durable line sink: content region + session history. */
|
|
51
|
+
private readonly emit: (line: string) => void,
|
|
52
|
+
/** Dock chrome around the status row (meta right, bottom hints). */
|
|
53
|
+
private readonly chrome: () => { meta: string | null; hints: string | null },
|
|
54
|
+
) {}
|
|
55
|
+
|
|
56
|
+
log(line: string): void {
|
|
57
|
+
this.emit(line);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
toolResult(name: string, firstLine: string): void {
|
|
61
|
+
this.emit(toolLine(name, firstLine));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private turnStartMs = Date.now();
|
|
65
|
+
|
|
66
|
+
turnStart(turn: number, maxTurns: number, tokensIn: number, tokensOut: number): void {
|
|
67
|
+
if (turn === 1) {
|
|
68
|
+
this.startMs = Date.now();
|
|
69
|
+
this.runSeed++;
|
|
70
|
+
}
|
|
71
|
+
this.turnStartMs = Date.now();
|
|
72
|
+
this.turn = turn;
|
|
73
|
+
this.maxTurns = maxTurns;
|
|
74
|
+
this.tokensIn = tokensIn;
|
|
75
|
+
this.tokensOut = tokensOut;
|
|
76
|
+
this.emit(dim(turnMarker(turn, maxTurns, tokensIn, tokensOut)));
|
|
77
|
+
this.arm();
|
|
78
|
+
this.paint();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
status(text: string | null): void {
|
|
82
|
+
this.busy = text;
|
|
83
|
+
if (text === null) this.streamedChars = 0;
|
|
84
|
+
this.paint();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
heartbeat(info: { elapsedMs: number; streamedChars: number }): void {
|
|
88
|
+
this.streamedChars = info.streamedChars;
|
|
89
|
+
this.paint();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
finish(line: string): void {
|
|
93
|
+
this.disarm();
|
|
94
|
+
this.busy = null;
|
|
95
|
+
this.emit(styleOutcome(line));
|
|
96
|
+
// The next prompt's renderDock() takes the dock back over.
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Currently displayed working word; morphs letter by letter on change. */
|
|
100
|
+
private shownWord = '';
|
|
101
|
+
private targetWord = '';
|
|
102
|
+
/** -1 = settled; otherwise progress through erase-then-type transition. */
|
|
103
|
+
private morph = -1;
|
|
104
|
+
|
|
105
|
+
private arm(): void {
|
|
106
|
+
if (this.timer) return;
|
|
107
|
+
this.timer = setInterval(() => {
|
|
108
|
+
this.frame++;
|
|
109
|
+
// Sweep three positions per tick: a full word crossfade in under a second.
|
|
110
|
+
if (this.morph >= 0) this.morph += 3;
|
|
111
|
+
this.paint();
|
|
112
|
+
}, 120);
|
|
113
|
+
this.timer.unref?.();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private disarm(): void {
|
|
117
|
+
if (this.timer) clearInterval(this.timer);
|
|
118
|
+
this.timer = null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Pinned observability row painted inside the dock (same layout as the prompt). */
|
|
122
|
+
private paint(): void {
|
|
123
|
+
const w = Math.max(10, this.dock.cols() - 1);
|
|
124
|
+
const spinner = copper(FRAMES[this.frame % FRAMES.length]!);
|
|
125
|
+
// Claude Code-style working word, board-shop themed: rotates every ~6s
|
|
126
|
+
// while a turn runs, with a shimmering highlight sweeping the letters.
|
|
127
|
+
// A word change morphs letter by letter: the old word is erased into
|
|
128
|
+
// `_` slots left to right, then the new word types over them.
|
|
129
|
+
const wordIdx =
|
|
130
|
+
(this.runSeed + this.turn + Math.floor((Date.now() - this.turnStartMs) / 6000)) %
|
|
131
|
+
WORKING.length;
|
|
132
|
+
const target = WORKING[wordIdx]!;
|
|
133
|
+
if (this.shownWord === '') this.shownWord = target;
|
|
134
|
+
if (target !== this.targetWord) {
|
|
135
|
+
this.targetWord = target;
|
|
136
|
+
if (target !== this.shownWord) this.morph = 0;
|
|
137
|
+
}
|
|
138
|
+
let text = `${this.shownWord}...`;
|
|
139
|
+
if (this.morph >= 0) {
|
|
140
|
+
// Single left-to-right sweep over the whole dotted string (dots
|
|
141
|
+
// included): each position flips old char -> `_` -> new char, so the
|
|
142
|
+
// words cross-fade character by character.
|
|
143
|
+
const oldS = `${this.shownWord}...`;
|
|
144
|
+
const newS = `${this.targetWord}...`;
|
|
145
|
+
const width = Math.max(oldS.length, newS.length);
|
|
146
|
+
const k = this.morph;
|
|
147
|
+
if (k >= width) {
|
|
148
|
+
this.shownWord = this.targetWord;
|
|
149
|
+
this.morph = -1;
|
|
150
|
+
text = `${this.shownWord}...`;
|
|
151
|
+
} else {
|
|
152
|
+
let out = '';
|
|
153
|
+
for (let i = 0; i < width; i++) {
|
|
154
|
+
out += i < k ? (newS[i] ?? ' ') : i === k ? '_' : (oldS[i] ?? ' ');
|
|
155
|
+
}
|
|
156
|
+
text = out.trimEnd();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// Fixed-width slot (longest word + dots) so the stats after it never
|
|
160
|
+
// shift; dots and morph slots share the word's copper.
|
|
161
|
+
const word = copper(text.padEnd(WORD_SLOT));
|
|
162
|
+
const parts = [
|
|
163
|
+
dim(`turn ${this.turn}/${this.maxTurns}`),
|
|
164
|
+
dim(`${fmtTokens(this.tokensIn)} in / ${fmtTokens(this.tokensOut)} out`),
|
|
165
|
+
dim(fmtDuration(Date.now() - this.startMs)),
|
|
166
|
+
];
|
|
167
|
+
if (this.busy) {
|
|
168
|
+
parts.push(
|
|
169
|
+
warn(this.streamedChars ? `${this.busy} ~${fmtTokens(this.streamedChars)} ch` : this.busy),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
const { meta, hints } = this.chrome();
|
|
173
|
+
this.dock.set([
|
|
174
|
+
...(meta ? [statusBar('', `${meta} `, w)] : []),
|
|
175
|
+
rule(w),
|
|
176
|
+
statusBar(`${spinner} ${word}`, `${parts.join(dim(' · '))} `, w),
|
|
177
|
+
rule(w),
|
|
178
|
+
...(hints ? [statusBar(` ${hints}`, '', w)] : []),
|
|
179
|
+
]);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal block-art mark derived from the website logo
|
|
3
|
+
* (docs/public/favicon.svg and docs.copperhead.sh): a via with a square
|
|
4
|
+
* drilled hole and long copper tracks routed out of both sides, copper
|
|
5
|
+
* #b87333 on dark. (`scripts/gen-logo.mjs` renders the exact favicon
|
|
6
|
+
* geometry at any size for reference.)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { copper } from './theme.js';
|
|
10
|
+
|
|
11
|
+
/** 3-row quadrant-block via with long tracks; rows are equal width. */
|
|
12
|
+
export function fiducialMark(): string[] {
|
|
13
|
+
return [
|
|
14
|
+
' ▄▟▙▄ ',
|
|
15
|
+
' ███ ███',
|
|
16
|
+
' ▀▜▛▀ ',
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The mark painted in brand copper. */
|
|
21
|
+
export function fiducialLines(): string[] {
|
|
22
|
+
return fiducialMark().map((line) => copper(line));
|
|
23
|
+
}
|
package/src/agent/loop.ts
CHANGED
|
@@ -3,21 +3,25 @@ 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';
|
|
14
|
+
import { styleHeaderLines } from './theme.js';
|
|
12
15
|
import { ObligationsLedger } from './ledger.js';
|
|
13
16
|
import { gitPreflight, isDirty, snapshot, restore, commitAll, changedFiles, preserveFailedRun } from '../util/git.js';
|
|
14
|
-
import { withRetry, isRateLimit } from '../util/retry.js';
|
|
17
|
+
import { withRetry, isRateLimit, sessionLimit } from '../util/retry.js';
|
|
15
18
|
import { openspecArchive } from '../openspec/cli.js';
|
|
16
19
|
import { existsSync } from 'node:fs';
|
|
17
20
|
import { OpenAIProvider } from './providers/openai.js';
|
|
18
21
|
import { AnthropicProvider } from './providers/anthropic.js';
|
|
19
22
|
import { CodexProvider } from './providers/codex.js';
|
|
20
23
|
import { ClaudeCodeProvider } from './providers/claude-code.js';
|
|
24
|
+
import { CursorProvider } from './providers/cursor.js';
|
|
21
25
|
import { openSynapMemory, type RunRecord, type SynapMemory } from '../memory/synap.js';
|
|
22
26
|
|
|
23
27
|
/** What the user sees at the moment they decide whether to keep going. */
|
|
@@ -63,9 +67,14 @@ export interface RunResult {
|
|
|
63
67
|
transcriptDir: string;
|
|
64
68
|
filesTouched: string[];
|
|
65
69
|
commit: string | null;
|
|
70
|
+
/** Cost/telemetry for this run. Surfaced by the create pipeline's per-stage
|
|
71
|
+
* cost table (5.2) so the expensive stages are obvious across runs. */
|
|
72
|
+
stats: RunStats;
|
|
73
|
+
/** Number of turns served from the on-disk response cache (5.2). */
|
|
74
|
+
cacheHits: number;
|
|
66
75
|
}
|
|
67
76
|
|
|
68
|
-
export async function makeProvider(model: string): Promise<Provider> {
|
|
77
|
+
export async function makeProvider(model: string, sessionResume = false): Promise<Provider> {
|
|
69
78
|
if (model === 'codex' || model.startsWith('codex:')) {
|
|
70
79
|
const codexModel = model.startsWith('codex:') ? model.slice('codex:'.length) : undefined;
|
|
71
80
|
if (codexModel === '') throw new Error('codex model override cannot be empty; use "codex" or "codex:<model-id>"');
|
|
@@ -91,7 +100,15 @@ export async function makeProvider(model: string): Promise<Provider> {
|
|
|
91
100
|
if (claudeCodeModel === '') {
|
|
92
101
|
throw new Error('claude-code model override cannot be empty; use "claude-code" or "claude-code:<model-id>"');
|
|
93
102
|
}
|
|
94
|
-
return new ClaudeCodeProvider(claudeCodeModel);
|
|
103
|
+
return new ClaudeCodeProvider(claudeCodeModel, undefined, undefined, sessionResume);
|
|
104
|
+
}
|
|
105
|
+
// Saved-login Cursor Agent CLI (`agent login`). Matched as its own namespace.
|
|
106
|
+
if (model === 'cursor' || model.startsWith('cursor:')) {
|
|
107
|
+
const cursorModel = model.startsWith('cursor:') ? model.slice('cursor:'.length) : undefined;
|
|
108
|
+
if (cursorModel === '') {
|
|
109
|
+
throw new Error('cursor model override cannot be empty; use "cursor" or "cursor:<model-id>"');
|
|
110
|
+
}
|
|
111
|
+
return new CursorProvider(cursorModel, undefined, sessionResume);
|
|
95
112
|
}
|
|
96
113
|
if (model === 'claude' || model.startsWith('claude')) {
|
|
97
114
|
return new AnthropicProvider(model === 'claude' ? undefined : model);
|
|
@@ -101,7 +118,7 @@ export async function makeProvider(model: string): Promise<Provider> {
|
|
|
101
118
|
|
|
102
119
|
function otherProvider(current: Provider): Provider | null {
|
|
103
120
|
// Only the two keyed providers fail over to each other. A rate-limited
|
|
104
|
-
// 'claude-code' run returns null here (no silent fallback to a paid API).
|
|
121
|
+
// 'claude-code' or 'cursor' run returns null here (no silent fallback to a paid API).
|
|
105
122
|
if (current.name === 'openai' && process.env.ANTHROPIC_API_KEY) return new AnthropicProvider();
|
|
106
123
|
if (current.name === 'anthropic' && process.env.OPENAI_API_KEY) return new OpenAIProvider();
|
|
107
124
|
return null;
|
|
@@ -198,8 +215,23 @@ async function runWithMemory(
|
|
|
198
215
|
finishRequest: null,
|
|
199
216
|
};
|
|
200
217
|
|
|
201
|
-
|
|
218
|
+
// Session resume for claude-code / cursor is only correct when the response
|
|
219
|
+
// cache is off: the cache replays turns a resumed session never saw. So enable
|
|
220
|
+
// it only when the env flag is set AND config.llmCache is disabled — the same
|
|
221
|
+
// condition under which we skip the CachingProvider wrap below.
|
|
222
|
+
const sessionResume = process.env.COPPERHEAD_CC_SESSION_RESUME === '1' && !config.llmCache;
|
|
223
|
+
let provider = opts.provider ?? (await makeProvider(opts.model, sessionResume));
|
|
224
|
+
// Cache every turn's response so a retried/restarted stage replays what it
|
|
225
|
+
// already paid for instead of re-calling the model (repo-scoped, cross-run).
|
|
226
|
+
// Skip an injected provider (tests drive scripted providers directly).
|
|
227
|
+
if (config.llmCache && !opts.provider) {
|
|
228
|
+
provider = new CachingProvider(provider, path.join(repoRoot, CONFIG_DIR, 'llm-cache'), log, opts.model);
|
|
229
|
+
}
|
|
202
230
|
providers.add(provider);
|
|
231
|
+
// Held separately from `provider` (which is reassigned on failover) so the
|
|
232
|
+
// final cache-hit count survives a mid-run provider switch (5.2).
|
|
233
|
+
const cachingProvider = provider instanceof CachingProvider ? provider : null;
|
|
234
|
+
const cacheHits = (): number => cachingProvider?.cacheHits ?? 0;
|
|
203
235
|
|
|
204
236
|
// Deterministic, LLM-free metadata block: collected once, rendered onto all
|
|
205
237
|
// three surfaces (run-start event, summary ## Environment, CLI header) so
|
|
@@ -216,7 +248,7 @@ async function runWithMemory(
|
|
|
216
248
|
interactive: opts.interactive ?? false,
|
|
217
249
|
input: opts.meta,
|
|
218
250
|
});
|
|
219
|
-
for (const line of renderCliHeader(meta)) log(line);
|
|
251
|
+
for (const line of styleHeaderLines(renderCliHeader(meta))) log(line);
|
|
220
252
|
// Revisit obligations deferred while their artifact didn't exist re-open now
|
|
221
253
|
// if it does (must run before loadConstraints so the prompt sees the updated
|
|
222
254
|
// registry). They land in this run's fresh ledger, so finish gates on them.
|
|
@@ -277,6 +309,8 @@ async function runWithMemory(
|
|
|
277
309
|
const perTurn: { turn: number; in: number; out: number }[] = [];
|
|
278
310
|
let plan: string | null = null;
|
|
279
311
|
let nudges = 0;
|
|
312
|
+
let turnTimeouts = 0;
|
|
313
|
+
const maxTurnTimeouts = 3;
|
|
280
314
|
|
|
281
315
|
const stats = (exitPath: ExitPath): RunStats => ({
|
|
282
316
|
exitPath,
|
|
@@ -356,6 +390,8 @@ async function runWithMemory(
|
|
|
356
390
|
transcriptDir: transcript.dir,
|
|
357
391
|
filesTouched: [],
|
|
358
392
|
commit: null,
|
|
393
|
+
stats: runStats,
|
|
394
|
+
cacheHits: cacheHits(),
|
|
359
395
|
};
|
|
360
396
|
};
|
|
361
397
|
|
|
@@ -388,15 +424,64 @@ async function runWithMemory(
|
|
|
388
424
|
await transcript.event('budget-extended', { extraTurns: extra, budget, ...exhaustStats });
|
|
389
425
|
log(`turn budget extended by ${extra} (now ${budget})`);
|
|
390
426
|
}
|
|
427
|
+
// Advertise EVERY tool each turn; dispatchTool enforces the edit-unlock gate
|
|
428
|
+
// live at call time. Hiding locked edit tools from the turn catalog meant a
|
|
429
|
+
// model that unlocked (validate_change) and edited in the SAME reply had its
|
|
430
|
+
// edit silently dropped in parsing — the call named a tool the turn had not
|
|
431
|
+
// advertised, so it was treated as prose, executed nothing, and returned no
|
|
432
|
+
// error. The model then "verified" against an unchanged file (an empty
|
|
433
|
+
// schematic even passes ERC) and finished believing it had succeeded.
|
|
434
|
+
// Structural lock (SPEC.md §1.3 invariant 1): the edit tools stay OUT of the
|
|
435
|
+
// advertised list until a proposal validates (`editsUnlocked`), so the model
|
|
436
|
+
// is gated by omission, not by prompt text. `dispatchTool` re-checks the same
|
|
437
|
+
// `availableTools(ctx)` live, so this is defense in depth. A premature edit is
|
|
438
|
+
// simply not offered; once `validate_change` unlocks, the next turn advertises
|
|
439
|
+
// the edit tools. (Earlier this advertised every tool to let a same-turn
|
|
440
|
+
// propose→validate→edit batch through, but that traded the spec's structural
|
|
441
|
+
// guarantee for one saved turn — not worth it.)
|
|
391
442
|
const tools = availableTools(ctx).map((t) => t.schema);
|
|
392
443
|
r.turnStart(turn + 1, maxTurns, tokensIn, tokensOut);
|
|
393
444
|
r.status('thinking');
|
|
394
445
|
let res: Turn;
|
|
446
|
+
// Liveness heartbeat (5.1): a large-output turn can legitimately run several
|
|
447
|
+
// minutes, which is otherwise indistinguishable from a hung subprocess until
|
|
448
|
+
// the watchdog fires. Emit a periodic elapsed/streamed signal so an operator
|
|
449
|
+
// can tell the two apart. Fires only after the first interval, so quick turns
|
|
450
|
+
// stay silent; `unref` keeps it from holding the event loop open.
|
|
451
|
+
const turnStartMs = Date.now();
|
|
452
|
+
let streamedChars = 0;
|
|
453
|
+
const heartbeat =
|
|
454
|
+
config.heartbeatMs > 0
|
|
455
|
+
? setInterval(
|
|
456
|
+
() => r.heartbeat({ elapsedMs: Date.now() - turnStartMs, streamedChars }),
|
|
457
|
+
config.heartbeatMs,
|
|
458
|
+
)
|
|
459
|
+
: null;
|
|
460
|
+
heartbeat?.unref?.();
|
|
395
461
|
try {
|
|
396
|
-
res = await withRetry(
|
|
397
|
-
|
|
398
|
-
|
|
462
|
+
res = await withRetry(
|
|
463
|
+
() =>
|
|
464
|
+
withTimeout(
|
|
465
|
+
() => provider.chat(messages, tools, { onStream: (chars) => (streamedChars = chars) }),
|
|
466
|
+
config.turnTimeoutMs,
|
|
467
|
+
() => provider.close?.(),
|
|
468
|
+
),
|
|
469
|
+
{ onRetry: (attempt) => log(`rate limited; retry ${attempt}`) },
|
|
470
|
+
);
|
|
399
471
|
} catch (err) {
|
|
472
|
+
if (err instanceof TurnTimeoutError) {
|
|
473
|
+
// A hung provider turn: the watchdog aborted the in-flight call and tore
|
|
474
|
+
// down its subprocess. Retry the same turn a bounded number of times
|
|
475
|
+
// before giving up, so a transient hang self-heals instead of stalling
|
|
476
|
+
// the run forever.
|
|
477
|
+
if (turnTimeouts++ < maxTurnTimeouts) {
|
|
478
|
+
log(`turn exceeded ${config.turnTimeoutMs}ms; aborted the hung call and retrying (${turnTimeouts}/${maxTurnTimeouts})`);
|
|
479
|
+
await transcript.event('turn-timeout', { ms: config.turnTimeoutMs, attempt: turnTimeouts });
|
|
480
|
+
turn--;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
return fail(`provider turns timed out ${turnTimeouts}× (>${config.turnTimeoutMs}ms each)`, 'provider-error');
|
|
484
|
+
}
|
|
400
485
|
if (isRateLimit(err)) {
|
|
401
486
|
const fallback = otherProvider(provider);
|
|
402
487
|
if (fallback) {
|
|
@@ -408,11 +493,34 @@ async function runWithMemory(
|
|
|
408
493
|
continue;
|
|
409
494
|
}
|
|
410
495
|
}
|
|
496
|
+
// A saved-login session/usage limit is not a code bug and not a 429 (2.4,
|
|
497
|
+
// I13): it names its own reset time and clears only then, and every turn
|
|
498
|
+
// so far is already in the llm-cache — so re-running after the reset
|
|
499
|
+
// replays them at ~0 tokens and resumes in place. Surface it as its own
|
|
500
|
+
// exit path with the reset time and the resume instruction, rather than a
|
|
501
|
+
// bare "provider error" the operator would read as a failure to debug.
|
|
502
|
+
const limit = sessionLimit(err);
|
|
503
|
+
if (limit) {
|
|
504
|
+
const when = limit.resetsAt ? ` (resets ${limit.resetsAt})` : '';
|
|
505
|
+
await transcript.event('session-limit', { resetsAt: limit.resetsAt, provider: provider.name });
|
|
506
|
+
return fail(
|
|
507
|
+
`${provider.name} session/usage limit reached${when} — this is a schedulable pause, not a bug. ` +
|
|
508
|
+
`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.`,
|
|
509
|
+
'session-limit',
|
|
510
|
+
);
|
|
511
|
+
}
|
|
411
512
|
return fail(`provider error: ${(err as Error).message}`, 'provider-error');
|
|
412
513
|
} finally {
|
|
514
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
413
515
|
r.status(null);
|
|
414
516
|
}
|
|
415
517
|
turnsUsed = turn + 1;
|
|
518
|
+
// A productive turn resets the timeout budget: maxTurnTimeouts is meant to
|
|
519
|
+
// catch a turn that is genuinely, repeatedly stuck — not to cap the total
|
|
520
|
+
// number of slow-but-recoverable turns across a whole stage. Without this a
|
|
521
|
+
// long stage that merely has a few independent slow turns accumulates
|
|
522
|
+
// timeouts and hard-fails even though every one of them recovered.
|
|
523
|
+
turnTimeouts = 0;
|
|
416
524
|
tokensIn += res.usage.inputTokens;
|
|
417
525
|
tokensOut += res.usage.outputTokens;
|
|
418
526
|
perTurn.push({ turn: turn + 1, in: res.usage.inputTokens, out: res.usage.outputTokens });
|
|
@@ -432,7 +540,9 @@ async function runWithMemory(
|
|
|
432
540
|
if (nudges++ >= 2) return fail('model stopped calling tools without finishing', 'stalled');
|
|
433
541
|
messages.push({
|
|
434
542
|
role: 'user',
|
|
435
|
-
|
|
543
|
+
// A near-miss malformed tool call (#I10) gets a specific steer to re-emit
|
|
544
|
+
// it; an ordinary tool-less turn gets the generic continue prompt.
|
|
545
|
+
content: res.nudge ?? 'Continue using tools, or call finish({outcome, summary}) to end the run.',
|
|
436
546
|
});
|
|
437
547
|
continue;
|
|
438
548
|
}
|
|
@@ -502,6 +612,8 @@ async function runWithMemory(
|
|
|
502
612
|
transcriptDir: transcript.dir,
|
|
503
613
|
filesTouched: [],
|
|
504
614
|
commit: null,
|
|
615
|
+
stats: runStats,
|
|
616
|
+
cacheHits: cacheHits(),
|
|
505
617
|
};
|
|
506
618
|
}
|
|
507
619
|
|
|
@@ -547,16 +659,32 @@ async function runWithMemory(
|
|
|
547
659
|
transcriptDir: transcript.dir,
|
|
548
660
|
filesTouched: files,
|
|
549
661
|
commit: null,
|
|
662
|
+
stats: runStats,
|
|
663
|
+
cacheHits: cacheHits(),
|
|
550
664
|
};
|
|
551
665
|
}
|
|
552
666
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
667
|
+
// Bookkeeping must never cost the verified design its commit (2.1): the
|
|
668
|
+
// KiCad work passed its ERC/DRC gates, so a failure appending the changelog
|
|
669
|
+
// (a plain CHANGELOG.md read+write) is a warning, not a rollback. It stays
|
|
670
|
+
// before commitAll so, on the normal path, the entry lands in the run's
|
|
671
|
+
// single commit and a zero-edit "done" run still has something to commit;
|
|
672
|
+
// if it throws, the design is committed without a changelog line rather
|
|
673
|
+
// than sent through fail()'s rollback. The other bookkeeping — the openspec
|
|
674
|
+
// archive — is already post-commit and non-fatal below.
|
|
675
|
+
try {
|
|
676
|
+
await appendChangelog(repoRoot, config, {
|
|
677
|
+
changeId: ctx.changeId,
|
|
678
|
+
request: opts.request,
|
|
679
|
+
files,
|
|
680
|
+
verification,
|
|
681
|
+
});
|
|
682
|
+
ctx.ledger.clear('changelog');
|
|
683
|
+
} catch (err) {
|
|
684
|
+
const message = (err as Error).message;
|
|
685
|
+
log(`warning: changelog append failed (${message}); committing the verified design without a changelog entry`);
|
|
686
|
+
await transcript.event('changelog-append-failed', { error: message });
|
|
687
|
+
}
|
|
560
688
|
|
|
561
689
|
const commitMsg = `copperhead: ${opts.request}\n\n${summary}\n\nVerification: ${verification}`;
|
|
562
690
|
// A git failure here (e.g. `git add -A` exiting 128 on an embedded repo)
|
|
@@ -619,6 +747,8 @@ async function runWithMemory(
|
|
|
619
747
|
transcriptDir: transcript.dir,
|
|
620
748
|
filesTouched: files,
|
|
621
749
|
commit,
|
|
750
|
+
stats: runStats,
|
|
751
|
+
cacheHits: cacheHits(),
|
|
622
752
|
};
|
|
623
753
|
}
|
|
624
754
|
}
|
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,
|