copperhead 0.9.0 → 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 (90) hide show
  1. package/NOTICE +1 -1
  2. package/README.md +6 -6
  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 +29 -58
  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/tool-protocol.js +21 -0
  12. package/dist/agent/providers/tool-protocol.js.map +1 -1
  13. package/dist/agent/recovery.js +95 -1
  14. package/dist/agent/recovery.js.map +1 -1
  15. package/dist/agent/tools.js +185 -1
  16. package/dist/agent/tools.js.map +1 -1
  17. package/dist/agent/transcript.js +2 -0
  18. package/dist/agent/transcript.js.map +1 -1
  19. package/dist/cli.js +75 -0
  20. package/dist/cli.js.map +1 -1
  21. package/dist/commands/check.js +33 -1
  22. package/dist/commands/check.js.map +1 -1
  23. package/dist/commands/create.js +177 -25
  24. package/dist/commands/create.js.map +1 -1
  25. package/dist/commands/doctor.js +50 -3
  26. package/dist/commands/doctor.js.map +1 -1
  27. package/dist/config.js +1 -0
  28. package/dist/config.js.map +1 -1
  29. package/dist/kicad/bootstrap.js +24 -3
  30. package/dist/kicad/bootstrap.js.map +1 -1
  31. package/dist/kicad/dossier.js +207 -0
  32. package/dist/kicad/dossier.js.map +1 -0
  33. package/dist/kicad/draft/draft.js +132 -0
  34. package/dist/kicad/draft/draft.js.map +1 -0
  35. package/dist/kicad/draft/engine.js +2389 -0
  36. package/dist/kicad/draft/engine.js.map +1 -0
  37. package/dist/kicad/draft/ir.js +368 -0
  38. package/dist/kicad/draft/ir.js.map +1 -0
  39. package/dist/kicad/draft/symsource.js +490 -0
  40. package/dist/kicad/draft/symsource.js.map +1 -0
  41. package/dist/kicad/emit.js +181 -0
  42. package/dist/kicad/emit.js.map +1 -0
  43. package/dist/kicad/fab.js +13 -0
  44. package/dist/kicad/fab.js.map +1 -1
  45. package/dist/kicad/legibility.js +561 -0
  46. package/dist/kicad/legibility.js.map +1 -0
  47. package/dist/kicad/score.js +261 -0
  48. package/dist/kicad/score.js.map +1 -0
  49. package/dist/kicad/sexp.js +239 -6
  50. package/dist/kicad/sexp.js.map +1 -1
  51. package/dist/kicad/symlib.js +346 -16
  52. package/dist/kicad/symlib.js.map +1 -1
  53. package/dist/memory/bom-table.js +75 -39
  54. package/dist/memory/bom-table.js.map +1 -1
  55. package/dist/memory/scaffold.js +6 -0
  56. package/dist/memory/scaffold.js.map +1 -1
  57. package/dist/util/redact.js +6 -0
  58. package/dist/util/redact.js.map +1 -1
  59. package/package.json +9 -7
  60. package/src/agent/filetools.ts +26 -1
  61. package/src/agent/ledger.ts +24 -0
  62. package/src/agent/loop.ts +28 -61
  63. package/src/agent/prompts.ts +4 -3
  64. package/src/agent/providers/tool-protocol.ts +22 -0
  65. package/src/agent/recovery.ts +94 -1
  66. package/src/agent/tools.ts +189 -1
  67. package/src/agent/transcript.ts +6 -0
  68. package/src/cli.ts +71 -0
  69. package/src/commands/check.ts +51 -1
  70. package/src/commands/create.ts +179 -20
  71. package/src/commands/doctor.ts +51 -3
  72. package/src/config.ts +24 -0
  73. package/src/kicad/bootstrap.ts +24 -3
  74. package/src/kicad/dossier.ts +217 -0
  75. package/src/kicad/draft/draft.ts +171 -0
  76. package/src/kicad/draft/engine.ts +2466 -0
  77. package/src/kicad/draft/ir.ts +416 -0
  78. package/src/kicad/draft/symsource.ts +535 -0
  79. package/src/kicad/emit.ts +236 -0
  80. package/src/kicad/fab.ts +15 -0
  81. package/src/kicad/legibility.ts +646 -0
  82. package/src/kicad/score.ts +323 -0
  83. package/src/kicad/sexp.ts +315 -6
  84. package/src/kicad/symlib.ts +364 -18
  85. package/src/memory/bom-table.ts +85 -38
  86. package/src/memory/scaffold.ts +6 -0
  87. package/src/util/redact.ts +6 -0
  88. package/dist/memory/synap.js +0 -152
  89. package/dist/memory/synap.js.map +0 -1
  90. package/src/memory/synap.ts +0 -217
package/src/kicad/sexp.ts CHANGED
@@ -22,7 +22,11 @@ export function parseSexp(text: string): SexpNode[] {
22
22
  let s = '';
23
23
  while (j < text.length && text[j] !== '"') {
24
24
  if (text[j] === '\\' && j + 1 < text.length) {
25
- s += text[j + 1];
25
+ // KiCad writes multi-line text as `\n` (and tabs as `\t`) inside the
26
+ // quoted atom; mapping the escape to the literal letter would give
27
+ // the legibility width model a one-line `line1nline2` string.
28
+ const e = text[j + 1];
29
+ s += e === 'n' ? '\n' : e === 't' ? '\t' : e!;
26
30
  j += 2;
27
31
  } else {
28
32
  s += text[j];
@@ -88,6 +92,8 @@ export interface SchematicSymbol {
88
92
  sheet: string;
89
93
  at: { x: number; y: number; rot: number };
90
94
  uuid: string;
95
+ /** `(unit N)` of the placed instance; 1 for single-unit symbols. */
96
+ unit: number;
91
97
  }
92
98
 
93
99
  export interface PinDef {
@@ -95,6 +101,9 @@ export interface PinDef {
95
101
  name: string;
96
102
  x: number;
97
103
  y: number;
104
+ /** Unit the pin belongs to (from the `_<unit>_<style>` child it sits in);
105
+ * 0 for a pin in the common unit, drawn on every placed unit. */
106
+ unit: number;
98
107
  }
99
108
 
100
109
  interface ParsedSheet {
@@ -126,7 +135,10 @@ async function loadSheets(rootSch: string): Promise<ParsedSheet[]> {
126
135
  return out;
127
136
  }
128
137
 
129
- /** Pin definitions per lib symbol name, in symbol coordinates. */
138
+ /** Pin definitions per lib symbol name, in symbol coordinates. Each pin
139
+ * carries the unit of the `_<unit>_<style>` child it was defined in (0 for
140
+ * the common unit); de Morgan alternates (style ≥ 2) are skipped so a pin is
141
+ * recorded once. */
130
142
  function libPinDefs(root: SexpNode[]): Map<string, PinDef[]> {
131
143
  const map = new Map<string, PinDef[]>();
132
144
  const libs = child(root, 'lib_symbols');
@@ -135,8 +147,15 @@ function libPinDefs(root: SexpNode[]): Map<string, PinDef[]> {
135
147
  const name = atomAt(sym, 1);
136
148
  if (!name) continue;
137
149
  const pins: PinDef[] = [];
138
- const walk = (n: SexpNode): void => {
150
+ const walk = (n: SexpNode, unit: number): void => {
139
151
  if (!isList(n)) return;
152
+ if (tag(n) === 'symbol') {
153
+ const m = /_(\d+)_(\d+)$/.exec(atomAt(n, 1) ?? '');
154
+ if (m) {
155
+ if (Number(m[2]) >= 2) return; // de Morgan alternate body style
156
+ unit = Number(m[1]);
157
+ }
158
+ }
140
159
  if (tag(n) === 'pin') {
141
160
  const at = child(n, 'at');
142
161
  const num = atomAt(child(n, 'number'), 1);
@@ -147,17 +166,26 @@ function libPinDefs(root: SexpNode[]): Map<string, PinDef[]> {
147
166
  name: pinName ?? '~',
148
167
  x: parseFloat(atomAt(at, 1) ?? '0'),
149
168
  y: parseFloat(atomAt(at, 2) ?? '0'),
169
+ unit,
150
170
  });
151
171
  }
152
172
  }
153
- for (const c of n) walk(c);
173
+ for (const c of n) walk(c, unit);
154
174
  };
155
- walk(sym);
175
+ walk(sym, 0);
156
176
  map.set(name, pins);
157
177
  }
158
178
  return map;
159
179
  }
160
180
 
181
+ /** The pins a PLACED instance actually shows: its own unit's plus the common
182
+ * unit's. A single-unit symbol keeps every pin (they are all unit ≤ 1). */
183
+ export function pinsOfUnit(defs: PinDef[], unit: number): PinDef[] {
184
+ const multi = defs.some((p) => p.unit >= 2);
185
+ if (!multi) return defs;
186
+ return defs.filter((p) => p.unit === 0 || p.unit === unit);
187
+ }
188
+
161
189
  /** Symbol-space → schematic-space transform (schematic Y grows downward). */
162
190
  export function pinAbsolute(
163
191
  symAt: { x: number; y: number; rot: number },
@@ -218,6 +246,7 @@ function symbolsOf(sheet: ParsedSheet): { node: SexpNode[]; sym: SchematicSymbol
218
246
  rot: parseFloat(atomAt(at, 3) ?? '0'),
219
247
  },
220
248
  uuid: atomAt(child(s, 'uuid'), 1) ?? '',
249
+ unit: parseInt(atomAt(child(s, 'unit'), 1) ?? '1', 10) || 1,
221
250
  },
222
251
  });
223
252
  }
@@ -282,6 +311,283 @@ export interface PinNet {
282
311
  net: string | null;
283
312
  }
284
313
 
314
+ // ---------- Read-only geometry accessors for the legibility checker ----------
315
+
316
+ export interface Bounds {
317
+ minX: number;
318
+ minY: number;
319
+ maxX: number;
320
+ maxY: number;
321
+ }
322
+
323
+ export interface WireSeg {
324
+ x1: number;
325
+ y1: number;
326
+ x2: number;
327
+ y2: number;
328
+ }
329
+
330
+ export interface TextItem {
331
+ text: string;
332
+ x: number;
333
+ y: number;
334
+ rot: number;
335
+ /** Font height in mm (KiCad `(size h w)`); 1.27 when unstated. */
336
+ height: number;
337
+ hidden: boolean;
338
+ }
339
+
340
+ export interface LabelItem {
341
+ name: string;
342
+ kind: 'label' | 'global_label' | 'hierarchical_label';
343
+ x: number;
344
+ y: number;
345
+ rot: number;
346
+ height: number;
347
+ /** Horizontal component of `(justify …)`, or null when unspecified. KiCad
348
+ * normalizes angles 180/270 to 0/90 at draw time and the stored justify
349
+ * describes that DRAWN frame, so a leftward label appears as angle 180 (no
350
+ * justify), angle 180 + justify right, or angle 0 + justify right — all the
351
+ * same box. */
352
+ justify: 'left' | 'right' | null;
353
+ }
354
+
355
+ export interface RectItem {
356
+ x1: number;
357
+ y1: number;
358
+ x2: number;
359
+ y2: number;
360
+ strokeType: string;
361
+ }
362
+
363
+ export interface PlacedSymbolGeom {
364
+ ref: string;
365
+ libId: string;
366
+ value: string;
367
+ at: { x: number; y: number; rot: number };
368
+ mirror: 'x' | 'y' | null;
369
+ isPower: boolean;
370
+ /** `(unit N)` of the placed instance; 1 for single-unit symbols. */
371
+ unit: number;
372
+ /** Reference and Value property text (absolute schematic coordinates). */
373
+ props: TextItem[];
374
+ }
375
+
376
+ export interface SheetGeometry {
377
+ filePath: string;
378
+ sheetName: string;
379
+ /** `(paper …)`: standard name, or explicit size when the file states one. */
380
+ paper: { name: string | null; width: number | null; height: number | null; portrait: boolean };
381
+ titleBlock: { title: string; date: string; rev: string } | null;
382
+ symbols: PlacedSymbolGeom[];
383
+ wires: WireSeg[];
384
+ labels: LabelItem[];
385
+ /** Free `(text …)` items (group captions live here). */
386
+ texts: TextItem[];
387
+ /** Sheet-level `(rectangle …)` graphics (group boxes live here). */
388
+ rectangles: RectItem[];
389
+ /** Union bounds of each lib symbol's body graphics, symbol space; null when the entry draws nothing. */
390
+ libBounds: Map<string, Bounds | null>;
391
+ libPins: Map<string, PinDef[]>;
392
+ }
393
+
394
+ const num = (n: SexpNode[] | undefined, idx: number, fallback = 0): number => {
395
+ const v = atomAt(n, idx);
396
+ return v === undefined ? fallback : parseFloat(v);
397
+ };
398
+
399
+ function effectsOf(node: SexpNode[]): { height: number; hidden: boolean } {
400
+ const effects = child(node, 'effects');
401
+ const font = effects ? child(effects, 'font') : undefined;
402
+ const size = font ? child(font, 'size') : undefined;
403
+ // hidden: legacy bare `hide` atom, or v9 `(hide yes)`
404
+ const hideNode = effects ? child(effects, 'hide') : undefined;
405
+ const hidden =
406
+ (effects?.some((c) => c === 'hide') ?? false) || (hideNode !== undefined && atomAt(hideNode, 1) !== 'no');
407
+ return { height: size ? num(size, 1, 1.27) : 1.27, hidden };
408
+ }
409
+
410
+ function libBodyBounds(root: SexpNode[]): Map<string, Bounds | null> {
411
+ const map = new Map<string, Bounds | null>();
412
+ const libs = child(root, 'lib_symbols');
413
+ if (!libs) return map;
414
+ for (const sym of children(libs, 'symbol')) {
415
+ const name = atomAt(sym, 1);
416
+ if (!name) continue;
417
+ /** Union across the whole symbol under `name`, per-unit union (common
418
+ * graphics included) under `name#<unit>` — a placed unit of a multi-unit
419
+ * symbol draws only its own unit's graphics, so measuring it with the
420
+ * package union would size every instance like the whole gate pack. */
421
+ const perUnit = new Map<number, Bounds | null>();
422
+ let b: Bounds | null = null;
423
+ const grow = (prev: Bounds | null, x: number, y: number): Bounds => {
424
+ if (!prev) return { minX: x, minY: y, maxX: x, maxY: y };
425
+ prev.minX = Math.min(prev.minX, x);
426
+ prev.minY = Math.min(prev.minY, y);
427
+ prev.maxX = Math.max(prev.maxX, x);
428
+ prev.maxY = Math.max(prev.maxY, y);
429
+ return prev;
430
+ };
431
+ const walk = (n: SexpNode, unit: number | null): void => {
432
+ if (!isList(n)) return;
433
+ const t = tag(n);
434
+ if (t === 'symbol') {
435
+ const m = /_(\d+)_(\d+)$/.exec(atomAt(n, 1) ?? '');
436
+ if (m) {
437
+ if (Number(m[2]) >= 2) return; // de Morgan alternate body style
438
+ unit = Number(m[1]);
439
+ }
440
+ }
441
+ const extend = (x: number, y: number): void => {
442
+ b = grow(b, x, y);
443
+ if (unit !== null) perUnit.set(unit, grow(perUnit.get(unit) ?? null, x, y));
444
+ };
445
+ if (t === 'rectangle') {
446
+ const s = child(n, 'start');
447
+ const e = child(n, 'end');
448
+ extend(num(s, 1), num(s, 2));
449
+ extend(num(e, 1), num(e, 2));
450
+ } else if (t === 'circle') {
451
+ const c = child(n, 'center');
452
+ const r = num(child(n, 'radius'), 1);
453
+ extend(num(c, 1) - r, num(c, 2) - r);
454
+ extend(num(c, 1) + r, num(c, 2) + r);
455
+ } else if (t === 'arc') {
456
+ for (const part of ['start', 'mid', 'end']) {
457
+ const p = child(n, part);
458
+ if (p) extend(num(p, 1), num(p, 2));
459
+ }
460
+ } else if (t === 'polyline') {
461
+ for (const xy of children(child(n, 'pts') ?? [], 'xy')) extend(num(xy, 1), num(xy, 2));
462
+ }
463
+ // pin/text graphics are deliberately excluded from the body box (design C2)
464
+ if (t !== 'pin' && t !== 'text') for (const c of n) walk(c, unit);
465
+ };
466
+ walk(sym, null);
467
+ map.set(name, b);
468
+ const common = perUnit.get(0) ?? null;
469
+ for (const [unit, ub] of perUnit) {
470
+ if (unit === 0) continue;
471
+ const merged =
472
+ ub && common
473
+ ? {
474
+ minX: Math.min(ub.minX, common.minX),
475
+ minY: Math.min(ub.minY, common.minY),
476
+ maxX: Math.max(ub.maxX, common.maxX),
477
+ maxY: Math.max(ub.maxY, common.maxY),
478
+ }
479
+ : (ub ?? (common ? { ...common } : null));
480
+ map.set(`${name}#${unit}`, merged);
481
+ }
482
+ }
483
+ return map;
484
+ }
485
+
486
+ function textItemsOf(sym: SexpNode[]): TextItem[] {
487
+ const out: TextItem[] = [];
488
+ for (const p of children(sym, 'property')) {
489
+ const key = atomAt(p, 1);
490
+ if (key !== 'Reference' && key !== 'Value') continue;
491
+ const at = child(p, 'at');
492
+ const fx = effectsOf(p);
493
+ out.push({
494
+ text: atomAt(p, 2) ?? '',
495
+ x: num(at, 1),
496
+ y: num(at, 2),
497
+ rot: num(at, 3),
498
+ height: fx.height,
499
+ hidden: fx.hidden,
500
+ });
501
+ }
502
+ return out;
503
+ }
504
+
505
+ /**
506
+ * Everything the legibility checker needs to see per sheet, extracted read-only.
507
+ * Never serializes and never mutates the file.
508
+ */
509
+ export async function readSheetGeometry(rootSch: string): Promise<SheetGeometry[]> {
510
+ const sheets = await loadSheets(rootSch);
511
+ const powerSyms = collectPowerSymbols(sheets);
512
+ return sheets.map((sheet) => {
513
+ const paperNode = child(sheet.root, 'paper');
514
+ const paperName = atomAt(paperNode, 1) ?? null;
515
+ const portrait = paperNode?.includes('portrait') ?? false;
516
+ const explicitW = num(paperNode, 2, NaN);
517
+ const explicitH = num(paperNode, 3, NaN);
518
+ const tb = child(sheet.root, 'title_block');
519
+ const tbField = (name: string): string => (tb ? (atomAt(child(tb, name), 1) ?? '') : '');
520
+ const wires: WireSeg[] = [];
521
+ for (const w of children(sheet.root, 'wire')) {
522
+ const pts = children(child(w, 'pts') ?? [], 'xy');
523
+ for (let i = 1; i < pts.length; i++) {
524
+ wires.push({ x1: num(pts[i - 1], 1), y1: num(pts[i - 1], 2), x2: num(pts[i], 1), y2: num(pts[i], 2) });
525
+ }
526
+ }
527
+ const labels: LabelItem[] = [];
528
+ for (const kind of ['label', 'global_label', 'hierarchical_label'] as const) {
529
+ for (const l of children(sheet.root, kind)) {
530
+ const at = child(l, 'at');
531
+ const effects = child(l, 'effects');
532
+ const justify = effects ? child(effects, 'justify') : undefined;
533
+ labels.push({
534
+ name: atomAt(l, 1) ?? '',
535
+ kind,
536
+ x: num(at, 1),
537
+ y: num(at, 2),
538
+ rot: num(at, 3),
539
+ height: effectsOf(l).height,
540
+ justify: justify?.some((c) => c === 'right') ? 'right' : justify?.some((c) => c === 'left') ? 'left' : null,
541
+ });
542
+ }
543
+ }
544
+ const texts: TextItem[] = children(sheet.root, 'text').map((t) => {
545
+ const at = child(t, 'at');
546
+ const fx = effectsOf(t);
547
+ return { text: atomAt(t, 1) ?? '', x: num(at, 1), y: num(at, 2), rot: num(at, 3), height: fx.height, hidden: fx.hidden };
548
+ });
549
+ const rectangles: RectItem[] = children(sheet.root, 'rectangle').map((r) => {
550
+ const s = child(r, 'start');
551
+ const e = child(r, 'end');
552
+ const stroke = child(r, 'stroke');
553
+ return {
554
+ x1: num(s, 1),
555
+ y1: num(s, 2),
556
+ x2: num(e, 1),
557
+ y2: num(e, 2),
558
+ strokeType: atomAt(child(stroke ?? [], 'type'), 1) ?? 'default',
559
+ };
560
+ });
561
+ return {
562
+ filePath: sheet.filePath,
563
+ sheetName: sheet.sheetName,
564
+ paper: {
565
+ name: paperName,
566
+ width: Number.isNaN(explicitW) ? null : explicitW,
567
+ height: Number.isNaN(explicitH) ? null : explicitH,
568
+ portrait,
569
+ },
570
+ titleBlock: tb ? { title: tbField('title'), date: tbField('date'), rev: tbField('rev') } : null,
571
+ symbols: symbolsOf(sheet).map(({ sym, mirror, node }) => ({
572
+ ref: sym.ref,
573
+ libId: sym.libId,
574
+ value: sym.value,
575
+ at: sym.at,
576
+ mirror,
577
+ isPower: isPowerSymbol(sym.libId, powerSyms),
578
+ unit: sym.unit,
579
+ props: textItemsOf(node),
580
+ })),
581
+ wires,
582
+ labels,
583
+ texts,
584
+ rectangles,
585
+ libBounds: libBodyBounds(sheet.root),
586
+ libPins: libPinDefs(sheet.root),
587
+ };
588
+ });
589
+ }
590
+
285
591
  /**
286
592
  * Geometric connectivity per sheet: pins, labels, and wire endpoints that share
287
593
  * coordinates (or are joined by wires) form a group; a group's net name comes
@@ -319,7 +625,10 @@ export async function pinNets(rootSch: string): Promise<PinNet[]> {
319
625
 
320
626
  const symPins: { sym: SchematicSymbol; pin: PinDef; k: string }[] = [];
321
627
  for (const { sym, mirror } of symbolsOf(sheet)) {
322
- const defs = pinDefs.get(sym.libId) ?? [];
628
+ // a placed unit of a multi-unit symbol shows only its own unit's pins;
629
+ // resolving the whole package at each instance would invent phantom
630
+ // connection points at every other unit's coordinates
631
+ const defs = pinsOfUnit(pinDefs.get(sym.libId) ?? [], sym.unit);
323
632
  for (const pin of defs) {
324
633
  const abs = pinAbsolute(sym.at, mirror, pin);
325
634
  const k = key(abs.x, abs.y);