copperhead 0.8.1 → 0.10.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.
Files changed (105) hide show
  1. package/NOTICE +1 -1
  2. package/README.md +13 -5
  3. package/dist/agent/filetools.js +24 -1
  4. package/dist/agent/filetools.js.map +1 -1
  5. package/dist/agent/ledger.js +24 -0
  6. package/dist/agent/ledger.js.map +1 -1
  7. package/dist/agent/loop.js +67 -62
  8. package/dist/agent/loop.js.map +1 -1
  9. package/dist/agent/prompts.js +4 -3
  10. package/dist/agent/prompts.js.map +1 -1
  11. package/dist/agent/providers/openai.js +28 -6
  12. package/dist/agent/providers/openai.js.map +1 -1
  13. package/dist/agent/providers/tool-protocol.js +21 -0
  14. package/dist/agent/providers/tool-protocol.js.map +1 -1
  15. package/dist/agent/recovery.js +95 -1
  16. package/dist/agent/recovery.js.map +1 -1
  17. package/dist/agent/response-cache.js +18 -2
  18. package/dist/agent/response-cache.js.map +1 -1
  19. package/dist/agent/tools.js +185 -1
  20. package/dist/agent/tools.js.map +1 -1
  21. package/dist/agent/transcript.js +2 -0
  22. package/dist/agent/transcript.js.map +1 -1
  23. package/dist/cli.js +77 -2
  24. package/dist/cli.js.map +1 -1
  25. package/dist/commands/check.js +33 -1
  26. package/dist/commands/check.js.map +1 -1
  27. package/dist/commands/create.js +282 -26
  28. package/dist/commands/create.js.map +1 -1
  29. package/dist/commands/doctor.js +211 -11
  30. package/dist/commands/doctor.js.map +1 -1
  31. package/dist/config.js +61 -4
  32. package/dist/config.js.map +1 -1
  33. package/dist/kicad/bootstrap.js +24 -3
  34. package/dist/kicad/bootstrap.js.map +1 -1
  35. package/dist/kicad/cli.js +7 -26
  36. package/dist/kicad/cli.js.map +1 -1
  37. package/dist/kicad/dossier.js +207 -0
  38. package/dist/kicad/dossier.js.map +1 -0
  39. package/dist/kicad/draft/draft.js +132 -0
  40. package/dist/kicad/draft/draft.js.map +1 -0
  41. package/dist/kicad/draft/engine.js +2389 -0
  42. package/dist/kicad/draft/engine.js.map +1 -0
  43. package/dist/kicad/draft/ir.js +368 -0
  44. package/dist/kicad/draft/ir.js.map +1 -0
  45. package/dist/kicad/draft/symsource.js +490 -0
  46. package/dist/kicad/draft/symsource.js.map +1 -0
  47. package/dist/kicad/emit.js +181 -0
  48. package/dist/kicad/emit.js.map +1 -0
  49. package/dist/kicad/fab.js +13 -0
  50. package/dist/kicad/fab.js.map +1 -1
  51. package/dist/kicad/legibility.js +561 -0
  52. package/dist/kicad/legibility.js.map +1 -0
  53. package/dist/kicad/score.js +261 -0
  54. package/dist/kicad/score.js.map +1 -0
  55. package/dist/kicad/sexp.js +262 -10
  56. package/dist/kicad/sexp.js.map +1 -1
  57. package/dist/kicad/symlib.js +346 -16
  58. package/dist/kicad/symlib.js.map +1 -1
  59. package/dist/memory/bom-table.js +108 -34
  60. package/dist/memory/bom-table.js.map +1 -1
  61. package/dist/memory/scaffold.js +6 -0
  62. package/dist/memory/scaffold.js.map +1 -1
  63. package/dist/openspec/cli.js +2 -1
  64. package/dist/openspec/cli.js.map +1 -1
  65. package/dist/util/preflight.js +17 -0
  66. package/dist/util/preflight.js.map +1 -1
  67. package/dist/util/redact.js +12 -2
  68. package/dist/util/redact.js.map +1 -1
  69. package/package.json +9 -7
  70. package/src/agent/filetools.ts +26 -1
  71. package/src/agent/ledger.ts +24 -0
  72. package/src/agent/loop.ts +88 -65
  73. package/src/agent/prompts.ts +4 -3
  74. package/src/agent/providers/openai.ts +38 -4
  75. package/src/agent/providers/tool-protocol.ts +22 -0
  76. package/src/agent/recovery.ts +94 -1
  77. package/src/agent/response-cache.ts +17 -1
  78. package/src/agent/tools.ts +189 -1
  79. package/src/agent/transcript.ts +6 -0
  80. package/src/cli.ts +73 -2
  81. package/src/commands/check.ts +51 -1
  82. package/src/commands/create.ts +278 -22
  83. package/src/commands/doctor.ts +219 -12
  84. package/src/config.ts +107 -2
  85. package/src/kicad/bootstrap.ts +24 -3
  86. package/src/kicad/cli.ts +6 -19
  87. package/src/kicad/dossier.ts +217 -0
  88. package/src/kicad/draft/draft.ts +171 -0
  89. package/src/kicad/draft/engine.ts +2466 -0
  90. package/src/kicad/draft/ir.ts +416 -0
  91. package/src/kicad/draft/symsource.ts +535 -0
  92. package/src/kicad/emit.ts +236 -0
  93. package/src/kicad/fab.ts +15 -0
  94. package/src/kicad/legibility.ts +646 -0
  95. package/src/kicad/score.ts +323 -0
  96. package/src/kicad/sexp.ts +339 -10
  97. package/src/kicad/symlib.ts +364 -18
  98. package/src/memory/bom-table.ts +119 -31
  99. package/src/memory/scaffold.ts +6 -0
  100. package/src/openspec/cli.ts +3 -2
  101. package/src/util/preflight.ts +18 -0
  102. package/src/util/redact.ts +12 -2
  103. package/dist/memory/synap.js +0 -152
  104. package/dist/memory/synap.js.map +0 -1
  105. package/src/memory/synap.ts +0 -217
package/src/agent/loop.ts CHANGED
@@ -7,7 +7,16 @@ import { CachingProvider } from './response-cache.js';
7
7
  import { withTimeout, TurnTimeoutError } from './recovery.js';
8
8
  import { buildSystemPrompt } from './prompts.js';
9
9
  import { loadConstraints, reopenDeferredAffects } from '../memory/constraints.js';
10
- import { loadConfig, CONFIG_DIR, type CopperheadConfig } from '../config.js';
10
+ import { isCreateProducedRepo, isEngineAuthoredSchematic } from '../kicad/fab.js';
11
+ import {
12
+ loadConfig,
13
+ CONFIG_DIR,
14
+ DEFAULT_API_KEY_ENV,
15
+ resolveCompatSettings,
16
+ isCompatModel,
17
+ type CompatSettings,
18
+ type CopperheadConfig,
19
+ } from '../config.js';
11
20
  import { Transcript, type ExitPath, type RunStats } from './transcript.js';
12
21
  import { collectRunMeta, renderCliHeader, type RunMeta, type RunMetaInput } from './runmeta.js';
13
22
  import { plainRenderer, fmtDuration, fmtTokens, type ProgressRenderer } from './render.js';
@@ -22,7 +31,6 @@ import { AnthropicProvider } from './providers/anthropic.js';
22
31
  import { CodexProvider } from './providers/codex.js';
23
32
  import { ClaudeCodeProvider } from './providers/claude-code.js';
24
33
  import { CursorProvider } from './providers/cursor.js';
25
- import { openSynapMemory, type RunRecord, type SynapMemory } from '../memory/synap.js';
26
34
 
27
35
  /** What the user sees at the moment they decide whether to keep going. */
28
36
  export interface BudgetExhaustedStats {
@@ -74,7 +82,43 @@ export interface RunResult {
74
82
  cacheHits: number;
75
83
  }
76
84
 
77
- export async function makeProvider(model: string, sessionResume = false): Promise<Provider> {
85
+ export async function makeProvider(
86
+ model: string,
87
+ sessionResume = false,
88
+ compat?: CompatSettings | undefined,
89
+ ): Promise<Provider> {
90
+ // OpenAI-compatible endpoint (Groq, OpenRouter, Gemini compat, local
91
+ // Ollama). An explicit `compat` prefix is the opt-in: nothing else
92
+ // consults baseURL, so a stray COPPERHEAD_BASE_URL cannot redirect a keyed
93
+ // `gpt-5` run to a third party (design D2).
94
+ if (isCompatModel(model)) {
95
+ const compatModel = model.startsWith('compat:') ? model.slice('compat:'.length) : undefined;
96
+ if (compatModel === '') {
97
+ throw new Error('compat model override cannot be empty; use "compat:<model-id>"');
98
+ }
99
+ const settings = compat ?? { apiKeyEnv: DEFAULT_API_KEY_ENV };
100
+ // Bare `compat` (no id) has no valid default: unlike gpt-5/claude, a
101
+ // compatible endpoint serves whatever models its host chooses, so there is
102
+ // no id that is ever correct to assume. Falling through here would build a
103
+ // provider that silently sends the literal string "gpt-5" (OpenAIProvider's
104
+ // own default) to a host that almost certainly does not serve it.
105
+ if (!compatModel) {
106
+ throw new Error(
107
+ settings.baseURL
108
+ ? `compat requires a model id; use "compat:<model-id>" (endpoint ${settings.baseURL} is configured, but has no default model)`
109
+ : 'compat requires a model id and an endpoint; use "compat:<model-id>" and set baseURL (COPPERHEAD_BASE_URL or .copperhead/config.json)',
110
+ );
111
+ }
112
+ if (!settings.baseURL) {
113
+ throw new Error(
114
+ `compat:${compatModel} requires an endpoint; set baseURL (COPPERHEAD_BASE_URL or "baseURL" in .copperhead/config.json) — without one this would silently fall back to the real OpenAI API.`,
115
+ );
116
+ }
117
+ return new OpenAIProvider(compatModel, {
118
+ baseURL: settings.baseURL,
119
+ apiKeyEnv: settings.apiKeyEnv,
120
+ });
121
+ }
78
122
  if (model === 'codex' || model.startsWith('codex:')) {
79
123
  const codexModel = model.startsWith('codex:') ? model.slice('codex:'.length) : undefined;
80
124
  if (codexModel === '') throw new Error('codex model override cannot be empty; use "codex" or "codex:<model-id>"');
@@ -158,16 +202,10 @@ async function appendChangelog(
158
202
  await writeFile(p, lines.join('\n'), 'utf8');
159
203
  }
160
204
 
161
- /**
162
- * Owns the Synap session for one run. The bridge is a subprocess, so the
163
- * shutdown in `finally` is what lets the CLI exit; without it the process
164
- * hangs after a successful run.
165
- */
166
205
  export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
167
- const memory = await openSynapMemory({ repoRoot: opts.repoRoot, log: opts.log });
168
206
  const providers = new Set<Provider>();
169
207
  try {
170
- return await runWithMemory(opts, memory, providers);
208
+ return await runWithProviders(opts, providers);
171
209
  } finally {
172
210
  for (const provider of providers) {
173
211
  try {
@@ -176,15 +214,10 @@ export async function runAgentLoop(opts: RunOptions): Promise<RunResult> {
176
214
  opts.log?.(`warning: ${provider.name} provider cleanup failed (${(err as Error).message})`);
177
215
  }
178
216
  }
179
- await memory?.close();
180
217
  }
181
218
  }
182
219
 
183
- async function runWithMemory(
184
- opts: RunOptions,
185
- memory: SynapMemory | null,
186
- providers: Set<Provider>,
187
- ): Promise<RunResult> {
220
+ async function runWithProviders(opts: RunOptions, providers: Set<Provider>): Promise<RunResult> {
188
221
  const r = opts.renderer ?? plainRenderer(opts.log ?? ((l: string) => console.log(l)));
189
222
  const log = (l: string): void => r.log(l);
190
223
  const repoRoot = opts.repoRoot;
@@ -196,11 +229,28 @@ async function runWithMemory(
196
229
 
197
230
  const transcript = new Transcript(repoRoot);
198
231
  await transcript.init();
232
+ // Legibility gates finish only where copperhead authored the sheet; a
233
+ // hand-drawn repo gets findings as information, never as a wedge (C6).
234
+ // Both conditions matter: the create-origin marker scopes the gate to repos
235
+ // this tool produced, and the generator stamp scopes it to sheets copperhead
236
+ // still owns. A human taking the sheet over in KiCad re-saves it under
237
+ // KiCad's generator, and from then on the gate must not defend a drawing
238
+ // the engine can no longer regenerate. A create repo whose schematic is not
239
+ // yet scaffolded keeps the gate: the sheet stage 4 will produce is
240
+ // copperhead-authored by construction.
241
+ let gateLegibility = isCreateProducedRepo(config);
242
+ if (gateLegibility && config.schematic) {
243
+ try {
244
+ gateLegibility = isEngineAuthoredSchematic(await readFile(path.join(repoRoot, config.schematic), 'utf8'));
245
+ } catch {
246
+ // schematic configured but absent (pre-scaffold): keep the gate
247
+ }
248
+ }
199
249
  const ctx: RunContext = {
200
250
  repoRoot,
201
251
  config,
202
252
  transcript,
203
- ledger: new ObligationsLedger(),
253
+ ledger: new ObligationsLedger(gateLegibility),
204
254
  runId: path.basename(transcript.dir),
205
255
  interactive: opts.interactive ?? false,
206
256
  confirm: opts.confirm ?? (async () => true),
@@ -210,6 +260,8 @@ async function runWithMemory(
210
260
  filesTouched: new Set(),
211
261
  decisions: [],
212
262
  lastErc: null,
263
+ lastLegibility: null,
264
+ lastScore: null,
213
265
  lastDrc: null,
214
266
  repairCycles: 0,
215
267
  finishRequest: null,
@@ -220,12 +272,24 @@ async function runWithMemory(
220
272
  // it only when the env flag is set AND config.llmCache is disabled — the same
221
273
  // condition under which we skip the CachingProvider wrap below.
222
274
  const sessionResume = process.env.COPPERHEAD_CC_SESSION_RESUME === '1' && !config.llmCache;
223
- let provider = opts.provider ?? (await makeProvider(opts.model, sessionResume));
275
+ const compatSettings = resolveCompatSettings(config);
276
+ let provider = opts.provider ?? (await makeProvider(opts.model, sessionResume, compatSettings));
224
277
  // Cache every turn's response so a retried/restarted stage replays what it
225
278
  // already paid for instead of re-calling the model (repo-scoped, cross-run).
226
279
  // Skip an injected provider (tests drive scripted providers directly).
227
280
  if (config.llmCache && !opts.provider) {
228
- provider = new CachingProvider(provider, path.join(repoRoot, CONFIG_DIR, 'llm-cache'), log, opts.model);
281
+ // Mirrors makeProvider's own gate above (D2/AC-3.16): COPPERHEAD_BASE_URL
282
+ // is consulted only for the explicit `compat:` route, so a gpt-5/claude
283
+ // run's cache key must not vary with a variable that run never reads —
284
+ // otherwise every non-compat cache entry gets orphaned each time the
285
+ // endpoint used for compat testing changes.
286
+ provider = new CachingProvider(
287
+ provider,
288
+ path.join(repoRoot, CONFIG_DIR, 'llm-cache'),
289
+ log,
290
+ opts.model,
291
+ isCompatModel(opts.model) ? compatSettings.baseURL : undefined,
292
+ );
229
293
  }
230
294
  providers.add(provider);
231
295
  // Held separately from `provider` (which is reassigned on failover) so the
@@ -272,37 +336,12 @@ async function runWithMemory(
272
336
  ...reopened.map((r) => `- ${r.key} affects ${r.item}`),
273
337
  ].join('\n');
274
338
  }
275
- // Cross-run memory is appended after the repo's own docs and constraints so
276
- // that the in-repo sources of truth are what the model reads first.
277
- const recalled = memory ? await memory.recall(opts.request) : null;
278
- if (recalled) {
279
- await transcript.event('synap-recall', { chars: recalled.length });
280
- log('recalled prior context from Synap memory');
281
- }
282
- const system = recalled ? `${basePrompt}\n\n${recalled}` : basePrompt;
283
339
  const messages: Msg[] = [
284
- { role: 'system', content: system },
340
+ { role: 'system', content: basePrompt },
285
341
  { role: 'user', content: opts.stagePrompt ? `${opts.stagePrompt}\n\nRequest: ${opts.request}` : opts.request },
286
342
  ];
287
343
  await transcript.event('run-start', meta);
288
344
 
289
- /**
290
- * A memory write that fails is reported rather than swallowed, but it does
291
- * not change the run's outcome: discarding a verified commit because a
292
- * third-party write failed would be the worse trade.
293
- */
294
- const remember = async (run: RunRecord): Promise<void> => {
295
- if (!memory) return;
296
- try {
297
- await memory.record(run);
298
- await transcript.event('synap-record', { outcome: run.outcome });
299
- } catch (err) {
300
- const message = (err as Error).message;
301
- log(`warning: Synap memory write failed (${message}); this run was not recorded`);
302
- await transcript.event('synap-record-failed', { error: message });
303
- }
304
- };
305
-
306
345
  let tokensIn = 0;
307
346
  let tokensOut = 0;
308
347
  let turnsUsed = 0;
@@ -360,6 +399,8 @@ async function runWithMemory(
360
399
  filesTouched: [...ctx.filesTouched],
361
400
  ercResult: ctx.lastErc ? (ctx.lastErc.ok ? 'clean' : `${ctx.lastErc.violations.length} violations`) : null,
362
401
  drcResult: ctx.lastDrc ? (ctx.lastDrc.ok ? 'clean' : `${ctx.lastDrc.violations.length} violations`) : null,
402
+ legibilityResult: ctx.lastLegibility ? `${ctx.lastLegibility.error} error, ${ctx.lastLegibility.advisory} advisory` : null,
403
+ scoreResult: ctx.lastScore !== null ? `${ctx.lastScore}/100` : null,
363
404
  decisions: ctx.decisions,
364
405
  tokensIn,
365
406
  tokensOut,
@@ -592,17 +633,6 @@ async function runWithMemory(
592
633
  env: meta,
593
634
  stats: runStats,
594
635
  });
595
- // Refusals are the most valuable thing to remember: they encode a budget
596
- // or constraint that this user's designs keep running into.
597
- await remember({
598
- request: opts.request,
599
- outcome: 'refused',
600
- summary,
601
- changeId: ctx.changeId,
602
- filesTouched: [],
603
- decisions: ctx.decisions,
604
- verification: 'n/a (refused before verification)',
605
- });
606
636
  log(`refused: ${summary}`);
607
637
  r.finish(outcomeLine(runStats));
608
638
  return {
@@ -721,6 +751,8 @@ async function runWithMemory(
721
751
  filesTouched: files,
722
752
  ercResult: ctx.lastErc ? (ctx.lastErc.ok ? 'clean' : 'FAILING') : 'not run',
723
753
  drcResult: ctx.lastDrc ? (ctx.lastDrc.ok ? 'clean' : 'FAILING') : 'not run',
754
+ legibilityResult: ctx.lastLegibility ? `${ctx.lastLegibility.error} error, ${ctx.lastLegibility.advisory} advisory` : null,
755
+ scoreResult: ctx.lastScore !== null ? `${ctx.lastScore}/100` : null,
724
756
  decisions: ctx.decisions,
725
757
  tokensIn,
726
758
  tokensOut,
@@ -729,15 +761,6 @@ async function runWithMemory(
729
761
  env: meta,
730
762
  stats: runStats,
731
763
  });
732
- await remember({
733
- request: opts.request,
734
- outcome: 'success',
735
- summary,
736
- changeId: ctx.changeId,
737
- filesTouched: files,
738
- decisions: ctx.decisions,
739
- verification,
740
- });
741
764
  log(`committed ${commit.slice(0, 10)} (${files.length} file(s))`);
742
765
  r.finish(outcomeLine(runStats, `committed ${commit.slice(0, 10)}`));
743
766
  return {
@@ -19,9 +19,10 @@ const WORKFLOW = `Workflow for every run:
19
19
  2. Call propose_change with a change id (kebab-case), why, what changes, and tasks. Then call validate_change. Edit tools (edit_file, write_file) unlock only after validation passes.
20
20
  3. Make the edits. Use the exact same net names and refdes everywhere. For .kicad_sch/.kicad_pcb use edit_file with unique anchors from the actual file text (read the file first). For renaming a net or refdes across a file, one edit_file call with replace_all: true beats many small edits.
21
21
  4. Run run_erc after schematic edits (and run_drc after board edits). If violations: read them, fix, re-run.
22
- 5. Run check_drift; update any doc that references a changed value/part/pin in the same run.
23
- 6. Record every non-trivial decision with record_decision, and every stated/assumed/discovered constraint with record_constraint.
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.
22
+ 5. After schematic edits also run check_legibility and reconcile every error-severity finding (advisories inform, they do not block). An electrically correct sheet that reads badly is not done: finish refuses while error findings are outstanding, the same way it refuses on a failing ERC.
23
+ 6. Run check_drift; update any doc that references a changed value/part/pin in the same run.
24
+ 7. Record every non-trivial decision with record_decision, and every stated/assumed/discovered constraint with record_constraint.
25
+ 8. 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
26
 
26
27
  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
28
  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.`;
@@ -1,18 +1,52 @@
1
+ import { DEFAULT_API_KEY_ENV, isLocalEndpoint } from '../../config.js';
1
2
  import type { ChatOpts, Msg, Provider, ToolSchema, Turn, ToolCall } from '../types.js';
2
3
 
4
+ /** Pointing the provider at an OpenAI-compatible endpoint (design D1). */
5
+ export interface OpenAIProviderOptions {
6
+ /** Endpoint base URL; omitted means the client's own default (OpenAI). */
7
+ baseURL?: string | undefined;
8
+ /** Name of the env var holding the key. Never the key itself. */
9
+ apiKeyEnv?: string | undefined;
10
+ }
11
+
3
12
  export class OpenAIProvider implements Provider {
4
- readonly name = 'openai';
13
+ readonly name: string;
14
+ private readonly apiKey: string | undefined;
15
+ private readonly baseURL: string | undefined;
5
16
 
6
17
  constructor(
7
18
  private readonly model = 'gpt-5',
8
- private readonly apiKey = process.env.OPENAI_API_KEY,
19
+ opts: OpenAIProviderOptions = {},
20
+ env: NodeJS.ProcessEnv = process.env,
9
21
  ) {
10
- if (!this.apiKey) throw new Error('OPENAI_API_KEY is not set');
22
+ // Credentials are always resolved through a named env var, never accepted
23
+ // as a literal value: the one way to supply a key keeps application code
24
+ // from ever holding one directly (mirrors AC-4.1 elsewhere). Tests inject
25
+ // fake values through the `env` argument, not through opts.
26
+ const keyEnv = opts.apiKeyEnv ?? DEFAULT_API_KEY_ENV;
27
+ this.baseURL = opts.baseURL;
28
+ // A compat endpoint must be structurally ineligible for the paid
29
+ // OpenAI/Anthropic failover in otherProvider() (loop.ts) — it is not
30
+ // OpenAI, and a rate limit there must never silently redirect a run the
31
+ // user deliberately pointed elsewhere to someone else's paid API.
32
+ this.name = this.baseURL ? 'openai-compat' : 'openai';
33
+ this.apiKey = env[keyEnv];
34
+ // A loopback endpoint (Ollama) serves the same API with no credential, and
35
+ // it is the one backend that is both free and fully local — requiring a
36
+ // dummy key there would be a papercut on the most useful config (D4).
37
+ if (!this.apiKey && !isLocalEndpoint(this.baseURL)) {
38
+ throw new Error(`${keyEnv} is not set`);
39
+ }
11
40
  }
12
41
 
13
42
  async chat(messages: Msg[], tools: ToolSchema[], opts: ChatOpts = {}): Promise<Turn> {
14
43
  const { default: OpenAI } = await import('openai');
15
- const client = new OpenAI({ apiKey: this.apiKey });
44
+ const client = new OpenAI({
45
+ // A local endpoint may legitimately have no key, but the client still
46
+ // wants a non-empty string, so send a placeholder it will never check.
47
+ apiKey: this.apiKey ?? 'no-key-required',
48
+ ...(this.baseURL ? { baseURL: this.baseURL } : {}),
49
+ });
16
50
  const res = await client.chat.completions.create({
17
51
  model: this.model,
18
52
  max_completion_tokens: opts.maxTokens ?? 8192,
@@ -85,9 +85,14 @@ export interface ParsedToolTurn {
85
85
  * split so only an inner `{args}` with no `tool` key balanced), not the tool
86
86
  * being broken. Returns a one-line steer to re-emit it, or undefined when the
87
87
  * absence of a call is genuine (plain prose, no tool named).
88
+ *
89
+ * Also covers the mirror case (I18): well-formed JSON naming a tool that is NOT in
90
+ * the catalog — withheld by the edit lock, or invented. That call is correctly not
91
+ * dispatched, but it must not be silent either.
88
92
  */
89
93
  function detectMalformedCall(text: string, catalog: Set<string>): string | undefined {
90
94
  const re = /"tool"\s*:\s*"([^"]+)"/g;
95
+ const offCatalog: string[] = [];
91
96
  let m: RegExpExecArray | null;
92
97
  while ((m = re.exec(text)) !== null) {
93
98
  const name = m[1]!;
@@ -98,6 +103,23 @@ function detectMalformedCall(text: string, catalog: Set<string>): string | undef
98
103
  'Re-emit it as exactly one complete JSON object: {"tool": "...", "args": { ... }}.'
99
104
  );
100
105
  }
106
+ if (!offCatalog.includes(name)) offCatalog.push(name);
107
+ }
108
+ // A well-formed call naming a tool the turn did not advertise — a locked edit or
109
+ // drafting tool before propose_change/validate_change, or an invented name.
110
+ // `toToolCall` is right to refuse it (the lock is structural, D2), but dropping it
111
+ // to prose tells the model NOTHING, and it fills that silence: fabricating the
112
+ // result it never got (I15), or concluding the engine is absent from the build and
113
+ // refusing the stage outright (I18). Withholding the tool is the invariant; hiding
114
+ // the reason never was, so name it and print the real catalog.
115
+ if (offCatalog.length) {
116
+ const named = offCatalog.map((n) => `"${n}"`).join(', ');
117
+ return (
118
+ `No call ran: ${named} ${offCatalog.length > 1 ? 'are' : 'is'} not in this turn's tool ` +
119
+ 'catalog. Edit and drafting tools are withheld until a proposal validates — call ' +
120
+ 'propose_change, then validate_change, and they appear. Do not conclude a tool is ' +
121
+ `missing from the build. Available this turn: ${[...catalog].join(', ')}.`
122
+ );
101
123
  }
102
124
  return undefined;
103
125
  }
@@ -2,6 +2,7 @@ import { existsSync } from 'node:fs';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import type { Msg, Provider } from './types.js';
5
+ import { resolveLibrarySymbol, searchInstalledSymbols, symbolSearchDirs, listInstalledLibraries } from '../kicad/symlib.js';
5
6
 
6
7
  /** Thrown when a single provider turn blows past its watchdog deadline. */
7
8
  export class TurnTimeoutError extends Error {
@@ -116,6 +117,91 @@ export async function transcriptExcerpt(transcriptDir: string, maxChars = 4000):
116
117
  return joined.length > maxChars ? joined.slice(joined.length - maxChars) : joined;
117
118
  }
118
119
 
120
+ /**
121
+ * Deterministically re-probe every lib_id named in a failure narrative against
122
+ * the installed libraries, so the diagnostician judges symbol-availability
123
+ * claims from machine facts instead of the agent's prose. An agent that has
124
+ * been dead-ended by wrong library nicknames concludes — and records — that
125
+ * whole libraries are absent when they are installed; a refusal built on that
126
+ * premise reads exactly like a genuine environmental gap, and the one thing
127
+ * that distinguishes them is re-checking the named lib_ids, which costs no
128
+ * LLM turn. Never throws; on any probe error it reports what it could.
129
+ */
130
+ export async function symbolAvailabilityFacts(text: string, dirs?: string[], cap = 8): Promise<string> {
131
+ const ids: string[] = [];
132
+ // A library nickname is its `.kicad_sym` filename stem, so it can carry `-`
133
+ // and `.` as well as `_` (`Custom-Parts`, `MyCorp.RF`) — a nickname the regex
134
+ // truncates is probed as the wrong lib_id and reported absent, which is the
135
+ // false negative this whole fact block exists to prevent. Separators are
136
+ // interior only, so a trailing sentence period is not swallowed.
137
+ for (const m of text.matchAll(/\b([A-Za-z0-9_](?:[A-Za-z0-9_.-]*[A-Za-z0-9_])?):([A-Za-z0-9][A-Za-z0-9_.+-]*)/g)) {
138
+ const lib = m[1]!;
139
+ const name = m[2]!;
140
+ // Require letters on both sides: drops file:line refs ("create.ts:311"),
141
+ // times and bare numbers. Engine-generated power symbols are not library
142
+ // facts.
143
+ if (!/[A-Za-z]/.test(lib) || !/[A-Za-z]/.test(name) || lib === 'copperhead_power') continue;
144
+ const libId = `${lib}:${name}`;
145
+ if (!ids.includes(libId)) ids.push(libId);
146
+ }
147
+ if (!ids.length) return '';
148
+ // Collection is unbounded but probing is capped, so a probe-heavy transcript
149
+ // stays cheap. The overflow is named rather than dropped: the supervisor is
150
+ // told these facts are ground truth, and silently probing 8 of 30 lib_ids
151
+ // would let it read "unprobed" as "absent".
152
+ const probed = ids.slice(0, cap);
153
+ const unprobed = ids.slice(cap);
154
+ let searchDirs: string[];
155
+ try {
156
+ searchDirs = dirs ?? (await symbolSearchDirs());
157
+ } catch {
158
+ return '';
159
+ }
160
+ if (!searchDirs.length) return '';
161
+ // A directory with no readable library means nothing was checked: emitting
162
+ // "not installed" lines as ground truth from that state is the exact false
163
+ // absence this block exists to prevent (same guard as bomSymbolDossier).
164
+ if (!(await listInstalledLibraries(searchDirs)).size) return '';
165
+ const lines: string[] = [];
166
+ for (const libId of probed) {
167
+ const name = libId.slice(libId.indexOf(':') + 1);
168
+ try {
169
+ const r = await resolveLibrarySymbol(libId, searchDirs);
170
+ if (r.status === 'ok') {
171
+ lines.push(`- ${libId}: RESOLVES on this machine (${r.pins.length} pins)`);
172
+ } else if (r.status === 'found-elsewhere') {
173
+ // The resolver already located the part under another lib_id; saying
174
+ // "not installed" here would be the exact false absence claim this
175
+ // block exists to prevent.
176
+ lines.push(`- ${libId}: not at that lib_id, but installed as: ${r.libIds.slice(0, 4).join(', ')}`);
177
+ } else {
178
+ const elsewhere = await searchInstalledSymbols(name, searchDirs, 4);
179
+ const inThat =
180
+ r.status === 'no-symbol' && r.candidates.length
181
+ ? ` (closest in that library: ${r.candidates.slice(0, 4).join(', ')})`
182
+ : '';
183
+ const where = elsewhere.length
184
+ ? `; installed as: ${elsewhere.join(', ')}`
185
+ : `; no installed symbol matches "${name}" in any library`;
186
+ lines.push(
187
+ r.status === 'no-symbol'
188
+ ? `- ${libId}: not in that library${inThat}${where}`
189
+ : `- ${libId}: no library of that nickname is installed${where}`,
190
+ );
191
+ }
192
+ } catch {
193
+ // a single unreadable library must not sink the fact block
194
+ }
195
+ }
196
+ if (!lines.length) return '';
197
+ if (unprobed.length) {
198
+ lines.push(
199
+ `- NOT RE-PROBED (probe limit ${cap}): ${unprobed.join(', ')} — these were named in the text but not checked, so nothing above says whether they exist.`,
200
+ );
201
+ }
202
+ return lines.join('\n');
203
+ }
204
+
119
205
  /**
120
206
  * Ask the model whether a failed/incomplete stage is worth retrying, and if so
121
207
  * how. Uses a fresh, tool-less provider turn (the same saved-login backend the
@@ -132,6 +218,9 @@ export async function diagnoseStageFailure(
132
218
  excerpt: string;
133
219
  attempt: number;
134
220
  maxAttempts: number;
221
+ /** Deterministic re-probe results for lib_ids named in the failure/excerpt
222
+ * (`symbolAvailabilityFacts`); authoritative over the transcript's claims. */
223
+ symbolFacts?: string;
135
224
  },
136
225
  ): Promise<StageDiagnosis> {
137
226
  const system =
@@ -145,10 +234,14 @@ export async function diagnoseStageFailure(
145
234
  `Failure: ${input.failure}\n` +
146
235
  `This was attempt ${input.attempt} of ${input.maxAttempts}.\n\n` +
147
236
  `Recent transcript (most recent last):\n${input.excerpt}\n\n` +
237
+ (input.symbolFacts
238
+ ? `Machine-verified symbol facts — a deterministic re-probe of the lib_ids named above, run just now against this machine's installed KiCad libraries. Each line reported below is ground truth and overrides anything the transcript claims about that symbol's availability. Coverage may be partial: a lib_id listed as NOT RE-PROBED, or absent from this block entirely, is unknown, never confirmed absent.\n${input.symbolFacts}\n\n`
239
+ : '') +
148
240
  'Reply with ONLY a JSON object, no prose:\n' +
149
241
  '{"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
242
  '- "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).';
243
+ '- "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).\n' +
244
+ '- an agent\'s claim that a symbol or library is absent is NOT evidence: agents dead-ended by wrong library nicknames routinely conclude whole libraries are missing. If the machine-verified facts contradict the failure\'s premise (a cited-absent lib_id RESOLVES, or the part is installed under another library), the verdict is "retry", with guidance quoting the correct lib_ids.';
152
245
  const messages: Msg[] = [
153
246
  { role: 'system', content: system },
154
247
  { role: 'user', content: user },
@@ -36,13 +36,29 @@ export class CachingProvider implements Provider {
36
36
  * in the cache key so switching model on the same repo does not replay the
37
37
  * other model's cached turns (F6). Falls back to the provider family name. */
38
38
  private readonly modelId?: string,
39
+ /** The compat endpoint's base URL, if any. A model id like `compat:llama-3.1-8b-instant`
40
+ * is not unique across hosts (Groq, OpenRouter, etc. all serve overlapping model
41
+ * ids), so the endpoint must be part of the key too, or two different hosts
42
+ * serving "the same" model id would share cached turns. */
43
+ private readonly baseURL?: string,
39
44
  ) {
40
45
  this.name = inner.name;
41
46
  }
42
47
 
43
48
  private keyFor(messages: Msg[], tools: ToolSchema[]): string {
44
49
  return createHash('sha256')
45
- .update(JSON.stringify({ model: this.modelId ?? this.name, messages, tools: tools.map((t) => t.name) }))
50
+ .update(
51
+ JSON.stringify({
52
+ model: this.modelId ?? this.name,
53
+ // Omitted rather than `?? null` when unset: a non-compat run's key
54
+ // must stay byte-identical to what it hashed before baseURL existed
55
+ // (F6/D2), or every pre-existing cache entry — not just compat ones —
56
+ // is orphaned on the first run after upgrade.
57
+ ...(this.baseURL ? { baseURL: this.baseURL } : {}),
58
+ messages,
59
+ tools: tools.map((t) => t.name),
60
+ }),
61
+ )
46
62
  .digest('hex');
47
63
  }
48
64