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
@@ -0,0 +1,323 @@
1
+ import { readSheetGeometry, type SheetGeometry, type Bounds } from './sexp.js';
2
+ import { checkLegibility, type LegibilityReport } from './legibility.js';
3
+ import type { LegibilityUserConfig } from '../config.js';
4
+
5
+ /**
6
+ * Deterministic quantitative scorer (design D7/D11). The checker decides
7
+ * pass/fail on unambiguous defects; this measures the judgment calls — wiring
8
+ * economy, alignment, symmetry, balance — as a weighted 0-100 composite whose
9
+ * per-metric breakdown is always reported. Any error-severity legibility
10
+ * finding caps the composite below the known-good floor, so a high score can
11
+ * never coexist with a gating defect. No LLM, no network, no subprocess.
12
+ */
13
+
14
+ export interface ScoreMetric {
15
+ name: string;
16
+ /** Raw measured value, full precision before rounding. */
17
+ raw: number;
18
+ /** Normalized goodness in [0, 1]. */
19
+ score: number;
20
+ weight: number;
21
+ /** Weighted contribution to the composite (points). */
22
+ contribution: number;
23
+ }
24
+
25
+ export interface ScoreReport {
26
+ composite: number;
27
+ metrics: ScoreMetric[];
28
+ /** Applied error-finding cap, or null. */
29
+ cap: { appliedAt: number; reason: string } | null;
30
+ legibility: { error: number; advisory: number };
31
+ }
32
+
33
+ const DEFAULT_WEIGHTS: Record<string, number> = {
34
+ 'wire-crossings': 10,
35
+ 'wire-bends': 5,
36
+ 'wire-length': 5,
37
+ 'label-to-wire-ratio': 5,
38
+ 'group-cohesion': 10,
39
+ 'flow-direction': 10,
40
+ utilization: 10,
41
+ 'axis-alignment': 10,
42
+ 'spacing-uniformity': 10,
43
+ 'straight-wire-ratio': 5,
44
+ 'label-alignment': 5,
45
+ 'whitespace-balance': 10,
46
+ 'pair-symmetry': 5,
47
+ };
48
+
49
+ /** Composite is capped this far below the Tier A floor when errors exist. */
50
+ const DEFAULT_FLOOR = 85;
51
+ const ERROR_CAP = 40;
52
+ /** Top-edge spread (mm) within which two group boxes count as the same row. */
53
+ const ROW_BAND = 12;
54
+
55
+ const PAPER: Record<string, { w: number; h: number }> = {
56
+ A5: { w: 210, h: 148 },
57
+ A4: { w: 297, h: 210 },
58
+ A3: { w: 420, h: 297 },
59
+ A2: { w: 594, h: 420 },
60
+ A1: { w: 841, h: 594 },
61
+ A0: { w: 1189, h: 841 },
62
+ };
63
+
64
+ interface Seg {
65
+ x1: number;
66
+ y1: number;
67
+ x2: number;
68
+ y2: number;
69
+ }
70
+
71
+ const segLen = (s: Seg): number => Math.hypot(s.x2 - s.x1, s.y2 - s.y1);
72
+ const isVert = (s: Seg): boolean => s.x1 === s.x2;
73
+
74
+ function properCrossing(a: Seg, b: Seg): boolean {
75
+ // orthogonal proper crossing (interiors intersect); shared endpoints are joins
76
+ const [v, h] = isVert(a) && !isVert(b) ? [a, b] : !isVert(a) && isVert(b) ? [b, a] : [null, null];
77
+ if (!v || !h) return false;
78
+ const [hy, vx] = [h.y1, v.x1];
79
+ const [hx1, hx2] = [Math.min(h.x1, h.x2), Math.max(h.x1, h.x2)];
80
+ const [vy1, vy2] = [Math.min(v.y1, v.y2), Math.max(v.y1, v.y2)];
81
+ return vx > hx1 + 0.01 && vx < hx2 - 0.01 && hy > vy1 + 0.01 && hy < vy2 - 0.01;
82
+ }
83
+
84
+ export interface ScoreOptions {
85
+ docsDir?: string | null;
86
+ config?: LegibilityUserConfig;
87
+ }
88
+
89
+ export async function scoreSchematic(rootSch: string, opts: ScoreOptions = {}): Promise<ScoreReport> {
90
+ const sheets = await readSheetGeometry(rootSch);
91
+ const legibility = await checkLegibility(rootSch, {
92
+ docsDir: opts.docsDir ?? null,
93
+ ...(opts.config ? { config: opts.config } : {}),
94
+ });
95
+ return scoreFromGeometry(sheets, legibility, opts.config);
96
+ }
97
+
98
+ export function scoreFromGeometry(
99
+ sheets: SheetGeometry[],
100
+ legibility: LegibilityReport,
101
+ config?: LegibilityUserConfig,
102
+ ): ScoreReport {
103
+ const weights = { ...DEFAULT_WEIGHTS };
104
+ for (const [k, v] of Object.entries(config?.score?.weights ?? {})) {
105
+ if (k in weights && typeof v === 'number' && Number.isFinite(v) && v >= 0) weights[k] = v;
106
+ }
107
+ // Sanitized like the weights above: a floor of 0 (or a non-number) would
108
+ // push the error cap `min(ERROR_CAP, floor - 1)` negative or NaN.
109
+ const rawFloor = config?.score?.floor;
110
+ const floor = typeof rawFloor === 'number' && Number.isFinite(rawFloor) && rawFloor >= 1 ? rawFloor : DEFAULT_FLOOR;
111
+
112
+ // ---- collect geometry across sheets ----
113
+ const segs: Seg[] = sheets.flatMap((s) => s.wires);
114
+ const labels = sheets.flatMap((s) => s.labels);
115
+ const realSyms = sheets.flatMap((s) =>
116
+ s.symbols.filter((y) => !y.isPower).map((y) => ({ ...y, sheet: s.sheetName })),
117
+ );
118
+
119
+ // wire crossings
120
+ let crossings = 0;
121
+ for (let i = 0; i < segs.length; i++) {
122
+ for (let j = i + 1; j < segs.length; j++) if (properCrossing(segs[i]!, segs[j]!)) crossings++;
123
+ }
124
+
125
+ // bends: points where exactly two segments of different orientation meet
126
+ const ends = new Map<string, Seg[]>();
127
+ for (const s of segs) {
128
+ for (const [x, y] of [[s.x1, s.y1], [s.x2, s.y2]] as const) {
129
+ const k = `${x},${y}`;
130
+ ends.set(k, [...(ends.get(k) ?? []), s]);
131
+ }
132
+ }
133
+ let bends = 0;
134
+ for (const list of ends.values()) {
135
+ if (list.length === 2 && isVert(list[0]!) !== isVert(list[1]!)) bends++;
136
+ }
137
+
138
+ const totalLen = segs.reduce((s, w) => s + segLen(w), 0);
139
+
140
+ // group cohesion: same-name label pairs, mean anchor distance vs sheet diagonal
141
+ const byName = new Map<string, { x: number; y: number }[]>();
142
+ for (const l of labels) byName.set(l.name, [...(byName.get(l.name) ?? []), { x: l.x, y: l.y }]);
143
+ const paper = PAPER[sheets[0]?.paper.name ?? 'A4'] ?? PAPER.A4!;
144
+ const diag = Math.hypot(paper.w, paper.h);
145
+ const pairDists: number[] = [];
146
+ for (const pts of byName.values()) {
147
+ for (let i = 0; i < pts.length; i++) {
148
+ for (let j = i + 1; j < pts.length; j++) pairDists.push(Math.hypot(pts[i]!.x - pts[j]!.x, pts[i]!.y - pts[j]!.y));
149
+ }
150
+ }
151
+ const meanPair = pairDists.length ? pairDists.reduce((a, b) => a + b, 0) / pairDists.length : 0;
152
+
153
+ // flow-direction: cross-group label nets should connect adjacent groups in
154
+ // reading order; a hop across intervening groups reads as against-the-flow
155
+ // wiring
156
+ const rects = sheets.flatMap((s) => s.rectangles.map((r) => ({
157
+ minX: Math.min(r.x1, r.x2),
158
+ maxX: Math.max(r.x1, r.x2),
159
+ minY: Math.min(r.y1, r.y2),
160
+ maxY: Math.max(r.y1, r.y2),
161
+ })));
162
+ // Reading order is rows first, then left-to-right within a row. Ordering by
163
+ // minX alone was correct only while every group sat in one band; on a sheet
164
+ // whose groups wrap onto several rows it interleaves row 2 back into row 1
165
+ // and reports flow violations for a drawing that reads perfectly.
166
+ const byRow = [...rects].sort((a, b) => a.minY - b.minY || a.minX - b.minX);
167
+ const rows: (typeof rects)[] = [];
168
+ for (const r of byRow) {
169
+ const row = rows[rows.length - 1];
170
+ // Rows are separated by a group box height; members of one row share a top
171
+ // edge to within a symbol cell, so a generous band still cannot merge two.
172
+ if (row && r.minY - row[0]!.minY <= ROW_BAND) row.push(r);
173
+ else rows.push([r]);
174
+ }
175
+ const ordered = rows.flatMap((row) => [...row].sort((a, b) => a.minX - b.minX));
176
+ const groupIdx = (x: number, y: number): number =>
177
+ ordered.findIndex((r) => x > r.minX && x < r.maxX && y > r.minY && y < r.maxY);
178
+ /** Which wrapped row a point falls in; -1 when it is in no group box. */
179
+ const rowIdx = (x: number, y: number): number =>
180
+ rows.findIndex((row) => row.some((r) => x > r.minX && x < r.maxX && y > r.minY && y < r.maxY));
181
+ let crossNets = 0;
182
+ let flowViolations = 0;
183
+ for (const pts of byName.values()) {
184
+ const gs = [...new Set(pts.map((p) => groupIdx(p.x, p.y)).filter((g) => g >= 0))];
185
+ if (gs.length > 1) {
186
+ crossNets++;
187
+ if (Math.max(...gs) - Math.min(...gs) > 1) flowViolations++;
188
+ }
189
+ }
190
+
191
+ // utilization + whitespace balance over the first sheet's usable frame
192
+ const items: Bounds[] = [
193
+ ...realSyms.map((s) => ({ minX: s.at.x - 2, minY: s.at.y - 2, maxX: s.at.x + 2, maxY: s.at.y + 2 })),
194
+ ...rects,
195
+ ];
196
+ const content = items.length
197
+ ? items.reduce((a, b) => ({
198
+ minX: Math.min(a.minX, b.minX),
199
+ minY: Math.min(a.minY, b.minY),
200
+ maxX: Math.max(a.maxX, b.maxX),
201
+ maxY: Math.max(a.maxY, b.maxY),
202
+ }))
203
+ : null;
204
+ const usable = { minX: 10, minY: 10, maxX: paper.w - 10, maxY: paper.h - 10 - 30 };
205
+ const usableArea = (usable.maxX - usable.minX) * (usable.maxY - usable.minY);
206
+ const utilization = content
207
+ ? Math.min(1, ((content.maxX - content.minX) * (content.maxY - content.minY)) / usableArea)
208
+ : 0;
209
+ const centroidOff = content
210
+ ? Math.hypot(
211
+ (content.minX + content.maxX) / 2 - (usable.minX + usable.maxX) / 2,
212
+ (content.minY + content.maxY) / 2 - (usable.minY + usable.maxY) / 2,
213
+ ) / (diag / 2)
214
+ : 1;
215
+
216
+ // axis alignment: symbols sharing an origin axis with at least one other
217
+ const sharesAxis = (i: number): boolean =>
218
+ realSyms.some((o, j) => j !== i && (Math.abs(o.at.x - realSyms[i]!.at.x) < 0.01 || Math.abs(o.at.y - realSyms[i]!.at.y) < 0.01));
219
+ const axisAligned = realSyms.length > 1 ? realSyms.filter((_, i) => sharesAxis(i)).length / realSyms.length : 1;
220
+
221
+ // spacing uniformity: variance of consecutive gaps within shared-x columns
222
+ const cols = new Map<string, number[]>();
223
+ for (const s of realSyms) {
224
+ // Keyed by row as well as x: a column is a vertical run the eye reads as
225
+ // one stack. Once groups wrap, two symbols in different rows can share an x
226
+ // while sitting a whole row apart, and treating that jump as a "gap" pushes
227
+ // the coefficient of variation — and the score — off a cliff.
228
+ const k = `${s.sheet}:${Math.round(s.at.x * 100)}:${rowIdx(s.at.x, s.at.y)}`;
229
+ cols.set(k, [...(cols.get(k) ?? []), s.at.y]);
230
+ }
231
+ const gapCVs: number[] = [];
232
+ for (const ys of cols.values()) {
233
+ if (ys.length < 3) continue;
234
+ ys.sort((a, b) => a - b);
235
+ const gaps = ys.slice(1).map((y, i) => y - ys[i]!);
236
+ const mean = gaps.reduce((a, b) => a + b, 0) / gaps.length;
237
+ const sd = Math.sqrt(gaps.reduce((a, g) => a + (g - mean) ** 2, 0) / gaps.length);
238
+ gapCVs.push(mean > 0 ? sd / mean : 0);
239
+ }
240
+ const spacingUniformity = gapCVs.length ? Math.max(0, 1 - gapCVs.reduce((a, b) => a + b, 0) / gapCVs.length) : 1;
241
+
242
+ // pair symmetry: same-libId+value symbols in one group aligned on a shared axis
243
+ const pairKeys = new Map<string, { x: number; y: number }[]>();
244
+ for (const s of realSyms) {
245
+ const g = groupIdx(s.at.x, s.at.y);
246
+ const k = `${g}:${s.libId}:${s.value}`;
247
+ pairKeys.set(k, [...(pairKeys.get(k) ?? []), { x: s.at.x, y: s.at.y }]);
248
+ }
249
+ let pairs = 0;
250
+ let symmetric = 0;
251
+ for (const pts of pairKeys.values()) {
252
+ if (pts.length < 2) continue;
253
+ for (let i = 0; i < pts.length; i++) {
254
+ for (let j = i + 1; j < pts.length; j++) {
255
+ pairs++;
256
+ if (Math.abs(pts[i]!.x - pts[j]!.x) < 0.01 || Math.abs(pts[i]!.y - pts[j]!.y) < 0.01) symmetric++;
257
+ }
258
+ }
259
+ }
260
+ const pairSymmetry = pairs ? symmetric / pairs : 1;
261
+
262
+ const horizLabels = labels.length
263
+ ? labels.filter((l) => Math.abs(l.rot % 180) !== 90).length / labels.length
264
+ : 1;
265
+ const straightRatio = segs.length ? Math.max(0, 1 - bends / segs.length) : 1;
266
+ const labelRatio = segs.length ? labels.length / segs.length : labels.length ? 1 : 0;
267
+
268
+ // ---- normalize to [0,1] goodness and compose ----
269
+ const raw: Record<string, { raw: number; score: number }> = {
270
+ 'wire-crossings': { raw: crossings, score: 1 / (1 + crossings) },
271
+ 'wire-bends': { raw: bends, score: 1 / (1 + bends / 8) },
272
+ 'wire-length': { raw: totalLen, score: 1 / (1 + totalLen / (4 * diag)) },
273
+ 'label-to-wire-ratio': { raw: labelRatio, score: labelRatio <= 1.5 ? 1 : 1.5 / labelRatio },
274
+ 'group-cohesion': { raw: meanPair, score: pairDists.length ? Math.max(0, 1 - meanPair / diag) : 1 },
275
+ 'flow-direction': { raw: flowViolations, score: crossNets ? 1 - flowViolations / crossNets : 1 },
276
+ utilization: { raw: utilization, score: Math.min(1, utilization / 0.5) },
277
+ 'axis-alignment': { raw: axisAligned, score: axisAligned },
278
+ 'spacing-uniformity': { raw: spacingUniformity, score: spacingUniformity },
279
+ 'straight-wire-ratio': { raw: straightRatio, score: straightRatio },
280
+ 'label-alignment': { raw: horizLabels, score: horizLabels },
281
+ 'whitespace-balance': { raw: centroidOff, score: Math.max(0, 1 - centroidOff) },
282
+ 'pair-symmetry': { raw: pairSymmetry, score: pairSymmetry },
283
+ };
284
+ const totalWeight = Object.values(weights).reduce((a, b) => a + b, 0) || 1;
285
+ const metrics: ScoreMetric[] = Object.entries(raw).map(([name, m]) => ({
286
+ name,
287
+ raw: m.raw,
288
+ score: m.score,
289
+ weight: weights[name]!,
290
+ contribution: (100 * weights[name]! * m.score) / totalWeight,
291
+ }));
292
+ let composite = metrics.reduce((a, m) => a + m.contribution, 0);
293
+
294
+ let cap: ScoreReport['cap'] = null;
295
+ if (legibility.counts.error > 0) {
296
+ const capValue = Math.min(ERROR_CAP, floor - 1);
297
+ if (composite > capValue) {
298
+ cap = {
299
+ appliedAt: capValue,
300
+ reason: `${legibility.counts.error} error-severity legibility finding(s) cap the composite below the known-good floor (${floor})`,
301
+ };
302
+ composite = capValue;
303
+ }
304
+ }
305
+
306
+ return {
307
+ composite: Math.round(composite * 100) / 100,
308
+ metrics,
309
+ cap,
310
+ legibility: legibility.counts,
311
+ };
312
+ }
313
+
314
+ export function formatScore(report: ScoreReport): string {
315
+ const lines = [`score: ${report.composite}/100`];
316
+ if (report.cap) lines.push(` cap: ${report.cap.appliedAt} — ${report.cap.reason}`);
317
+ for (const m of report.metrics) {
318
+ lines.push(
319
+ ` ${m.name}: raw ${Math.round(m.raw * 10000) / 10000}, score ${Math.round(m.score * 1000) / 1000}, weight ${m.weight}, contributes ${Math.round(m.contribution * 100) / 100}`,
320
+ );
321
+ }
322
+ return lines.join('\n');
323
+ }