copperhead 0.9.0 → 0.11.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 (142) hide show
  1. package/NOTICE +1 -1
  2. package/README.md +47 -8
  3. package/dist/agent/context.js +2 -0
  4. package/dist/agent/context.js.map +1 -0
  5. package/dist/agent/dock-renderer.js +2 -2
  6. package/dist/agent/dock-renderer.js.map +1 -1
  7. package/dist/agent/envelope.js +105 -0
  8. package/dist/agent/envelope.js.map +1 -0
  9. package/dist/agent/filetools.js +24 -1
  10. package/dist/agent/filetools.js.map +1 -1
  11. package/dist/agent/ledger.js +24 -0
  12. package/dist/agent/ledger.js.map +1 -1
  13. package/dist/agent/loop.js +63 -72
  14. package/dist/agent/loop.js.map +1 -1
  15. package/dist/agent/prompts.js +4 -3
  16. package/dist/agent/prompts.js.map +1 -1
  17. package/dist/agent/providers/claude-code.js +17 -1
  18. package/dist/agent/providers/claude-code.js.map +1 -1
  19. package/dist/agent/providers/codex.js +84 -39
  20. package/dist/agent/providers/codex.js.map +1 -1
  21. package/dist/agent/providers/tool-protocol.js +21 -0
  22. package/dist/agent/providers/tool-protocol.js.map +1 -1
  23. package/dist/agent/recovery.js +186 -15
  24. package/dist/agent/recovery.js.map +1 -1
  25. package/dist/agent/registry.js +49 -0
  26. package/dist/agent/registry.js.map +1 -0
  27. package/dist/agent/render.js +2 -2
  28. package/dist/agent/render.js.map +1 -1
  29. package/dist/agent/theme.js +10 -5
  30. package/dist/agent/theme.js.map +1 -1
  31. package/dist/agent/tools.js +100 -586
  32. package/dist/agent/tools.js.map +1 -1
  33. package/dist/agent/transcript.js +2 -0
  34. package/dist/agent/transcript.js.map +1 -1
  35. package/dist/capabilities/define.js +35 -0
  36. package/dist/capabilities/define.js.map +1 -0
  37. package/dist/capabilities/handlers.js +744 -0
  38. package/dist/capabilities/handlers.js.map +1 -0
  39. package/dist/capabilities/helpers.js +39 -0
  40. package/dist/capabilities/helpers.js.map +1 -0
  41. package/dist/capabilities/index.js +50 -0
  42. package/dist/capabilities/index.js.map +1 -0
  43. package/dist/capabilities/skills/generate-report.js +23 -0
  44. package/dist/capabilities/skills/generate-report.js.map +1 -0
  45. package/dist/cli.js +158 -0
  46. package/dist/cli.js.map +1 -1
  47. package/dist/commands/check.js +33 -1
  48. package/dist/commands/check.js.map +1 -1
  49. package/dist/commands/create.js +181 -26
  50. package/dist/commands/create.js.map +1 -1
  51. package/dist/commands/doctor.js +83 -6
  52. package/dist/commands/doctor.js.map +1 -1
  53. package/dist/commands/skill.js +109 -0
  54. package/dist/commands/skill.js.map +1 -0
  55. package/dist/commands/sync.js +3 -1
  56. package/dist/commands/sync.js.map +1 -1
  57. package/dist/config.js +19 -6
  58. package/dist/config.js.map +1 -1
  59. package/dist/kicad/bootstrap.js +24 -3
  60. package/dist/kicad/bootstrap.js.map +1 -1
  61. package/dist/kicad/cli.js +106 -18
  62. package/dist/kicad/cli.js.map +1 -1
  63. package/dist/kicad/dossier.js +207 -0
  64. package/dist/kicad/dossier.js.map +1 -0
  65. package/dist/kicad/draft/draft.js +135 -0
  66. package/dist/kicad/draft/draft.js.map +1 -0
  67. package/dist/kicad/draft/engine.js +5310 -0
  68. package/dist/kicad/draft/engine.js.map +1 -0
  69. package/dist/kicad/draft/ir.js +368 -0
  70. package/dist/kicad/draft/ir.js.map +1 -0
  71. package/dist/kicad/draft/symsource.js +504 -0
  72. package/dist/kicad/draft/symsource.js.map +1 -0
  73. package/dist/kicad/emit.js +220 -0
  74. package/dist/kicad/emit.js.map +1 -0
  75. package/dist/kicad/fab.js +13 -0
  76. package/dist/kicad/fab.js.map +1 -1
  77. package/dist/kicad/legibility.js +608 -0
  78. package/dist/kicad/legibility.js.map +1 -0
  79. package/dist/kicad/score.js +431 -0
  80. package/dist/kicad/score.js.map +1 -0
  81. package/dist/kicad/sexp.js +265 -6
  82. package/dist/kicad/sexp.js.map +1 -1
  83. package/dist/kicad/symlib.js +346 -16
  84. package/dist/kicad/symlib.js.map +1 -1
  85. package/dist/mcp/server.js +485 -0
  86. package/dist/mcp/server.js.map +1 -0
  87. package/dist/memory/bom-table.js +75 -39
  88. package/dist/memory/bom-table.js.map +1 -1
  89. package/dist/memory/scaffold.js +14 -1
  90. package/dist/memory/scaffold.js.map +1 -1
  91. package/dist/util/redact.js +6 -0
  92. package/dist/util/redact.js.map +1 -1
  93. package/package.json +13 -8
  94. package/src/agent/context.ts +35 -0
  95. package/src/agent/dock-renderer.ts +3 -2
  96. package/src/agent/envelope.ts +124 -0
  97. package/src/agent/filetools.ts +26 -1
  98. package/src/agent/ledger.ts +24 -0
  99. package/src/agent/loop.ts +73 -78
  100. package/src/agent/prompts.ts +4 -3
  101. package/src/agent/providers/claude-code.ts +22 -1
  102. package/src/agent/providers/codex.ts +91 -42
  103. package/src/agent/providers/tool-protocol.ts +22 -0
  104. package/src/agent/recovery.ts +183 -13
  105. package/src/agent/registry.ts +58 -0
  106. package/src/agent/render.ts +4 -3
  107. package/src/agent/theme.ts +15 -5
  108. package/src/agent/tools.ts +124 -628
  109. package/src/agent/transcript.ts +6 -0
  110. package/src/agent/types.ts +10 -5
  111. package/src/capabilities/define.ts +88 -0
  112. package/src/capabilities/handlers.ts +769 -0
  113. package/src/capabilities/helpers.ts +37 -0
  114. package/src/capabilities/index.ts +53 -0
  115. package/src/capabilities/skills/generate-report.ts +25 -0
  116. package/src/cli.ts +154 -0
  117. package/src/commands/check.ts +51 -1
  118. package/src/commands/create.ts +183 -21
  119. package/src/commands/doctor.ts +85 -6
  120. package/src/commands/skill.ts +127 -0
  121. package/src/commands/sync.ts +5 -3
  122. package/src/config.ts +56 -8
  123. package/src/kicad/bootstrap.ts +24 -3
  124. package/src/kicad/cli.ts +129 -18
  125. package/src/kicad/dossier.ts +217 -0
  126. package/src/kicad/draft/draft.ts +173 -0
  127. package/src/kicad/draft/engine.ts +5274 -0
  128. package/src/kicad/draft/ir.ts +416 -0
  129. package/src/kicad/draft/symsource.ts +549 -0
  130. package/src/kicad/emit.ts +300 -0
  131. package/src/kicad/fab.ts +15 -0
  132. package/src/kicad/legibility.ts +695 -0
  133. package/src/kicad/score.ts +502 -0
  134. package/src/kicad/sexp.ts +346 -6
  135. package/src/kicad/symlib.ts +364 -18
  136. package/src/mcp/server.ts +560 -0
  137. package/src/memory/bom-table.ts +85 -38
  138. package/src/memory/scaffold.ts +14 -1
  139. package/src/util/redact.ts +6 -0
  140. package/dist/memory/synap.js +0 -152
  141. package/dist/memory/synap.js.map +0 -1
  142. package/src/memory/synap.ts +0 -217
@@ -35,18 +35,92 @@ export interface TableRow {
35
35
  * whatever cells they split into — this function never throws.
36
36
  */
37
37
  export function parseMarkdownTables(md: string): TableRow[] {
38
- const rows: TableRow[] = [];
39
- for (const line of md.split('\n')) {
38
+ return (
39
+ scanTableGroups(md)
40
+ // A group opened by an un-piped line with no delimiter row is not a table:
41
+ // GFM requires the delimiter row for one to exist at all. Without this,
42
+ // ordinary prose such as `Second-source options: Yageo | Vishay | KOA`
43
+ // under a BOM table becomes a part in `export bom`, which `check` cannot
44
+ // catch because `parseCanonicalTables` drops the header-less group.
45
+ .filter((g) => g[0]!.piped || g.some((l) => l.separator))
46
+ .flat()
47
+ .filter((l) => !l.separator)
48
+ .map((l) => ({ cells: l.cells }))
49
+ );
50
+ }
51
+
52
+ interface ScannedLine {
53
+ cells: string[];
54
+ separator: boolean;
55
+ /** Whether the source line carried a leading pipe. */
56
+ piped: boolean;
57
+ }
58
+
59
+ /**
60
+ * Group a document's table lines, one group per table. Both readers below go
61
+ * through this so they cannot disagree about which documents are readable:
62
+ * `check` accepting a BOM.md that `export bom` reads as empty is how a clean
63
+ * check turns into a header-only ordering file.
64
+ *
65
+ * Lines inside a fenced code block are skipped. Docs legitimately show the
66
+ * table format as an example, and an example row is not a part or a pin.
67
+ */
68
+ function scanTableGroups(md: string): ScannedLine[][] {
69
+ const groups: ScannedLine[][] = [];
70
+ let current: ScannedLine[] | null = null;
71
+ let width = 0; // column count of the open group, set by its first line
72
+ // The open fence's delimiter, or null outside one. A fence closes only on the
73
+ // same character, at least as long (CommonMark). Toggling on any fence-like
74
+ // line would let a shorter or different delimiter *inside* a block end it
75
+ // early, and the example rows after it would be read as parts or pins.
76
+ let fence: { char: string; length: number } | null = null;
77
+ // Split on either line ending: a CRLF document would otherwise leave a
78
+ // trailing \r on every line, and \r is a line terminator that `.` does not
79
+ // match, so the fence patterns below would never fire on a CRLF file.
80
+ for (const line of md.split(/\r?\n/)) {
40
81
  const t = line.trim();
41
- if (!t.startsWith('|')) continue;
42
- const cells = t
43
- .split('|')
44
- .slice(1, -1)
45
- .map((c) => c.trim());
46
- if (cells.every((c) => /^:?-+:?$/.test(c))) continue; // separator row
47
- rows.push({ cells });
82
+ // Matched against the raw line: CommonMark allows a fence up to three
83
+ // spaces of indentation, and four or more makes it content rather than a
84
+ // fence. Trimming first would let an indented delimiter inside a block
85
+ // read as a closer.
86
+ const delim = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
87
+ if (delim) {
88
+ const marker = delim[1]!;
89
+ const char = marker[0]!;
90
+ if (!fence) {
91
+ fence = { char, length: marker.length };
92
+ current = null;
93
+ continue;
94
+ }
95
+ // A closing fence carries no info string; anything else stays content.
96
+ if (char === fence.char && marker.length >= fence.length && !delim[2]!.trim()) {
97
+ fence = null;
98
+ current = null;
99
+ continue;
100
+ }
101
+ }
102
+ if (fence || !t.includes('|')) {
103
+ current = null; // a blank, prose, or fenced line terminates the table
104
+ continue;
105
+ }
106
+ const cells = splitRow(t);
107
+ // Outer pipes make a line unambiguously a table row. Without them, a
108
+ // pipe-bearing prose line (`Legend: A | B`) is indistinguishable from a
109
+ // row by shape alone, so the column count decides: matching the open
110
+ // table's width keeps it as a row, a mismatch ends the table there rather
111
+ // than reading the prose as a part/pin. The line still opens a new group,
112
+ // in case it is itself the header of an un-piped table.
113
+ if (current && !t.startsWith('|') && cells.length !== width) current = null;
114
+ if (!current) {
115
+ current = [];
116
+ width = cells.length;
117
+ groups.push(current);
118
+ }
119
+ // The separator row stays in the group so the header can be located
120
+ // relative to it; it is dropped by the readers above and below.
121
+ current.push({ cells, separator: isSeparatorRow(cells), piped: t.startsWith('|') });
48
122
  }
49
- return rows;
123
+ return groups;
50
124
  }
51
125
 
52
126
  /** True for a table's header row. BOM.md and PINOUT.md both lead with a
@@ -86,35 +160,8 @@ export interface TableRow {
86
160
  * it loops on finish forever. Resolving by header name fixes that.
87
161
  */
88
162
  export function parseCanonicalTables(md: string): Array<{ header: TableRow; rows: TableRow[] }> {
89
- type Line = { cells: string[]; separator: boolean };
90
- const groups: Line[][] = [];
91
- let current: Line[] | null = null;
92
- let width = 0; // column count of the open group, set by its first line
93
- for (const line of md.split('\n')) {
94
- const t = line.trim();
95
- if (!t.includes('|')) {
96
- current = null; // a blank or prose line terminates the current table
97
- continue;
98
- }
99
- const cells = splitRow(t);
100
- // Outer pipes make a line unambiguously a table row. Without them, a
101
- // pipe-bearing prose line (`Legend: A | B`) is indistinguishable from a
102
- // row by shape alone, so the column count decides: matching the open
103
- // table's width keeps it as a row, a mismatch ends the table there rather
104
- // than reading the prose as a part/pin. The line still opens a new group,
105
- // in case it is itself the header of an un-piped table.
106
- if (current && !t.startsWith('|') && cells.length !== width) current = null;
107
- if (!current) {
108
- current = [];
109
- width = cells.length;
110
- groups.push(current);
111
- }
112
- // The separator row stays in the group so the header can be located
113
- // relative to it; it is dropped from the rows returned below.
114
- current.push({ cells, separator: isSeparatorRow(cells) });
115
- }
116
163
  const tables: Array<{ header: TableRow; rows: TableRow[] }> = [];
117
- for (const g of groups) {
164
+ for (const g of scanTableGroups(md)) {
118
165
  // The header is the row directly above the separator. Falling back to the
119
166
  // first row keeps a table that omits the separator working, and anchoring
120
167
  // on the separator means a stray pipe-bearing prose line immediately above
@@ -4,6 +4,7 @@ import { createHash } from 'node:crypto';
4
4
  import path from 'node:path';
5
5
  import { listSymbols, pinNets, type SchematicSymbol, type PinNet } from '../kicad/sexp.js';
6
6
  import { configPath, loadConfig, type CopperheadConfig } from '../config.js';
7
+ import { resolveInRepo } from '../util/paths.js';
7
8
 
8
9
  export class InitError extends Error {}
9
10
 
@@ -69,6 +70,11 @@ What the device is, top-level constraints and budgets.
69
70
  Every part: refdes, MPN, value, package, and WHY it was chosen. MPNs the agent
70
71
  introduces without a datasheet check are flagged UNVERIFIED.
71
72
 
73
+ Value holds the component value only ("4.7uF", "1M", "500mAh Li-Po") — the
74
+ schematic stage draws it as the symbol's Value field, so a description there
75
+ collides with neighbouring symbols and fails the legibility gate. Prose belongs
76
+ in Rationale. One row per refdes; a grouped row ("C5-C8") matches no symbol.
77
+
72
78
  ${bomTable(symbols)}
73
79
  `,
74
80
  'PINOUT.md': `# Pinout
@@ -119,6 +125,7 @@ Generated by \`copperhead init\`; regenerated on re-runs (do not hand-edit).
119
125
  - \`maxRepairCycles\`: ERC/DRC repair attempts before rollback (default 5)
120
126
  - \`budgets\`: free-form hard constraints (e.g. \`"sleep_current_uA": 25\`); surfaced verbatim into every run's system prompt
121
127
  - \`generatedHashes\`: content hashes of generated docs, used to detect hand edits on re-init
128
+ - \`legibility\` (optional): schematic legibility checker tuning. \`legibility.thresholds\` accepts \`gridPitch\` (default 1.27mm), \`minPitch\` (2.54mm edge-to-edge between symbol bodies), \`utilization\` (0.5 of the usable frame), \`maxWireLength\` (50.8mm), and \`familyCap\` (10 findings per family per sheet). \`legibility.severity.<kind>\` overrides any check family to \`error\`, \`advisory\`, or \`off\` (disables it). Absent keys use the defaults; \`check\` reports findings without failing, the \`create\` schematic stage gates on error-severity findings. \`legibility.score.weights.<metric>\` and \`legibility.score.floor\` tune the quantitative scorer.
122
129
 
123
130
  ## constraints.json
124
131
 
@@ -166,7 +173,13 @@ export interface InitOptions {
166
173
 
167
174
  export async function runInit(opts: InitOptions): Promise<InitResult> {
168
175
  const { repoRoot } = opts;
169
- const searchRoot = path.resolve(repoRoot, opts.searchPath ?? '.');
176
+ // AC-4.2: the search root comes from an untrusted caller — a CLI flag, and
177
+ // now an MCP tool input chosen by a host agent. Bare path.resolve lets an
178
+ // absolute or `../` value win outright, which would scaffold this repo's docs
179
+ // from a foreign project and durably write an out-of-repo config.schematic
180
+ // that every later check/do would then operate on. Contain it like every
181
+ // other file path in the codebase.
182
+ const searchRoot = resolveInRepo(repoRoot, opts.searchPath ?? '.');
170
183
  const { sch, pcb } = await findKicadFiles(searchRoot);
171
184
  if (!sch) {
172
185
  throw new InitError(
@@ -15,6 +15,12 @@ const PATTERNS: RegExp[] = [
15
15
  /npm_[A-Za-z0-9-]{36,}/g,
16
16
  /gh[pousr]_[A-Za-z0-9]{36,}/g,
17
17
  /github_pat_[A-Za-z0-9_]{22,}/g,
18
+ // Gemini and Groq keys: same idea as sk- above, just a different prefix.
19
+ // Real keys are AIza + 35 chars, but AIzaSy (the common fifth/sixth pair)
20
+ // isn't the only prefix Google issues - match on AIza alone so other
21
+ // Google keys aren't left unredacted.
22
+ /AIza[A-Za-z0-9_-]{20,}/g,
23
+ /gsk_[A-Za-z0-9]{20,}/g,
18
24
  ];
19
25
 
20
26
  export function redactSecrets(text: string): string {
@@ -1,152 +0,0 @@
1
- /**
2
- * Cross-run memory via Synap (https://docs.maximem.ai).
3
- *
4
- * copperhead's own memory is per-repo and file-based: docs/DECISIONS.md, the
5
- * constraint registry, the drift checker. Synap is the layer above that — what
6
- * this user tends to want across every board they've ever asked about. It
7
- * complements the docs; it does not replace them, and nothing here is a source
8
- * of truth. Recalled text is advisory context in the system prompt, while
9
- * as-built facts still come from the KiCad files.
10
- *
11
- * Three properties are load-bearing:
12
- *
13
- * 1. Opt-in. Inactive unless SYNAP_API_KEY is set, so the default install is
14
- * unchanged and `check` stays network-free (it never constructs this).
15
- * 2. Optional at runtime. @maximem/synap-js-sdk is an optionalDependency and is
16
- * imported lazily through a non-literal specifier, so a missing package (or
17
- * a host without the Python 3.11+ runtime its bridge needs) degrades to
18
- * "no memory" instead of breaking the CLI.
19
- * 3. Fail-soft on read, loud on write. A recall failure must never cost someone
20
- * a design run; a record failure is reported, because silently losing writes
21
- * lets memory drift away from what actually happened.
22
- */
23
- import { randomUUID } from 'node:crypto';
24
- import { execa } from 'execa';
25
- import { redactSecrets } from '../util/redact.js';
26
- /** Wall-clock ceiling on any single Synap call. The bridge is a Python subprocess. */
27
- const RECALL_TIMEOUT_MS = 10_000;
28
- const RECORD_TIMEOUT_MS = 15_000;
29
- const MAX_RECALLED = 8;
30
- export function synapEnabled(env = process.env) {
31
- return Boolean(env.SYNAP_API_KEY);
32
- }
33
- /**
34
- * Identity for memory scoping. The git committer email is the natural stable
35
- * user id here: it is already the identity every run is attributed to.
36
- */
37
- async function resolveUserId(repoRoot, env) {
38
- if (env.SYNAP_USER_ID)
39
- return env.SYNAP_USER_ID;
40
- try {
41
- const { stdout } = await execa('git', ['config', 'user.email'], { cwd: repoRoot });
42
- if (stdout.trim())
43
- return stdout.trim();
44
- }
45
- catch {
46
- // not configured; fall through
47
- }
48
- return 'copperhead-local';
49
- }
50
- function withTimeout(p, ms, label) {
51
- return new Promise((resolve, reject) => {
52
- const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
53
- p.then((v) => {
54
- clearTimeout(timer);
55
- resolve(v);
56
- }, (e) => {
57
- clearTimeout(timer);
58
- reject(e);
59
- });
60
- });
61
- }
62
- /**
63
- * Returns null when memory is disabled or unavailable — every caller treats
64
- * null as "run without cross-run memory".
65
- */
66
- export async function openSynapMemory(opts) {
67
- const env = opts.env ?? process.env;
68
- const log = opts.log ?? (() => { });
69
- if (!synapEnabled(env))
70
- return null;
71
- let client;
72
- try {
73
- // Non-literal specifier: keeps tsc from resolving an optional dependency at
74
- // build time, so the package may legitimately be absent.
75
- const specifier = '@maximem/synap-js-sdk';
76
- const mod = (await import(specifier));
77
- const createClient = mod.createClient ?? mod.default?.createClient;
78
- if (!createClient)
79
- throw new Error('createClient not exported');
80
- client = createClient({ apiKey: env.SYNAP_API_KEY, requestTimeoutMs: RECORD_TIMEOUT_MS });
81
- await withTimeout(client.init(), RECORD_TIMEOUT_MS, 'synap init');
82
- }
83
- catch (err) {
84
- // Missing package, missing Python runtime, bad key: all non-fatal.
85
- log(`synap memory unavailable (${err.message}); continuing without it`);
86
- return null;
87
- }
88
- const userId = await resolveUserId(opts.repoRoot, env);
89
- const customerId = env.SYNAP_CUSTOMER_ID ?? 'copperhead';
90
- const conversationId = randomUUID();
91
- return {
92
- async recall(request) {
93
- try {
94
- const res = await withTimeout(client.searchMemory({
95
- userId,
96
- customerId,
97
- query: redactSecrets(request),
98
- maxResults: MAX_RECALLED,
99
- }), RECALL_TIMEOUT_MS, 'synap recall');
100
- const items = (res.results ?? []).filter((r) => r.memory?.trim());
101
- if (!items.length)
102
- return null;
103
- const lines = items.map((r) => `- ${r.memory.trim()}${r.contextType ? ` _(${r.contextType})_` : ''}`);
104
- return [
105
- '## Recalled from prior runs (Synap)',
106
- '',
107
- 'Context from earlier work by this user, possibly on other boards. Advisory only:',
108
- 'the KiCad files and this repo’s docs remain the source of truth. If any of this',
109
- 'conflicts with what you read in the repo, the repo wins — say so rather than',
110
- 'acting on a stale memory.',
111
- '',
112
- ...lines,
113
- ].join('\n');
114
- }
115
- catch (err) {
116
- log(`synap recall failed (${err.message}); continuing without it`);
117
- return null;
118
- }
119
- },
120
- async record(run) {
121
- const assistant = [
122
- `Outcome: ${run.outcome}`,
123
- `Summary: ${run.summary}`,
124
- `OpenSpec change: ${run.changeId ?? 'n/a'}`,
125
- `Verification: ${run.verification}`,
126
- run.filesTouched.length ? `Files: ${run.filesTouched.join(', ')}` : null,
127
- run.decisions.length ? `Decisions:\n${run.decisions.map((d) => `- ${d}`).join('\n')}` : null,
128
- ]
129
- .filter(Boolean)
130
- .join('\n');
131
- await withTimeout(client.addMemory({
132
- userId,
133
- customerId,
134
- conversationId,
135
- messages: [
136
- { role: 'user', content: redactSecrets(run.request) },
137
- { role: 'assistant', content: redactSecrets(assistant) },
138
- ],
139
- metadata: { source: 'copperhead', outcome: run.outcome, changeId: run.changeId },
140
- }), RECORD_TIMEOUT_MS, 'synap record');
141
- },
142
- async close() {
143
- try {
144
- await withTimeout(client.shutdown(), RECORD_TIMEOUT_MS, 'synap shutdown');
145
- }
146
- catch {
147
- // Best effort: a failed shutdown must not change the run's outcome.
148
- }
149
- },
150
- };
151
- }
152
- //# sourceMappingURL=synap.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"synap.js","sourceRoot":"","sources":["../../src/memory/synap.ts"],"names":[],"mappings":"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"}
@@ -1,217 +0,0 @@
1
- /**
2
- * Cross-run memory via Synap (https://docs.maximem.ai).
3
- *
4
- * copperhead's own memory is per-repo and file-based: docs/DECISIONS.md, the
5
- * constraint registry, the drift checker. Synap is the layer above that — what
6
- * this user tends to want across every board they've ever asked about. It
7
- * complements the docs; it does not replace them, and nothing here is a source
8
- * of truth. Recalled text is advisory context in the system prompt, while
9
- * as-built facts still come from the KiCad files.
10
- *
11
- * Three properties are load-bearing:
12
- *
13
- * 1. Opt-in. Inactive unless SYNAP_API_KEY is set, so the default install is
14
- * unchanged and `check` stays network-free (it never constructs this).
15
- * 2. Optional at runtime. @maximem/synap-js-sdk is an optionalDependency and is
16
- * imported lazily through a non-literal specifier, so a missing package (or
17
- * a host without the Python 3.11+ runtime its bridge needs) degrades to
18
- * "no memory" instead of breaking the CLI.
19
- * 3. Fail-soft on read, loud on write. A recall failure must never cost someone
20
- * a design run; a record failure is reported, because silently losing writes
21
- * lets memory drift away from what actually happened.
22
- */
23
- import { randomUUID } from 'node:crypto';
24
- import { execa } from 'execa';
25
- import { redactSecrets } from '../util/redact.js';
26
-
27
- /** Wall-clock ceiling on any single Synap call. The bridge is a Python subprocess. */
28
- const RECALL_TIMEOUT_MS = 10_000;
29
- const RECORD_TIMEOUT_MS = 15_000;
30
- const MAX_RECALLED = 8;
31
-
32
- /**
33
- * Structural subset of @maximem/synap-js-sdk's surface. Declared locally rather
34
- * than imported so `tsc` succeeds when the optional dependency is absent.
35
- */
36
- interface SynapSearchItem {
37
- memory: string;
38
- score?: number;
39
- contextType?: string;
40
- }
41
- interface SynapClientLike {
42
- init(): Promise<void>;
43
- searchMemory(input: {
44
- userId: string;
45
- customerId?: string;
46
- query: string;
47
- maxResults?: number;
48
- }): Promise<{ results: SynapSearchItem[] }>;
49
- addMemory(input: {
50
- userId: string;
51
- customerId: string;
52
- conversationId?: string;
53
- messages: Array<{ role?: 'user' | 'assistant'; content: string }>;
54
- metadata?: Record<string, unknown>;
55
- }): Promise<{ success: boolean }>;
56
- shutdown(): Promise<void>;
57
- }
58
-
59
- export interface RunRecord {
60
- request: string;
61
- outcome: 'success' | 'refused';
62
- summary: string;
63
- changeId: string | null;
64
- filesTouched: string[];
65
- decisions: string[];
66
- verification: string;
67
- }
68
-
69
- export interface SynapMemory {
70
- /** Prior context relevant to this request, as a prompt-ready markdown block. */
71
- recall(request: string): Promise<string | null>;
72
- /** Persist a finished run. Rejects if the write fails. */
73
- record(run: RunRecord): Promise<void>;
74
- /** Stop the bridge subprocess. Always call this, or the CLI will not exit. */
75
- close(): Promise<void>;
76
- }
77
-
78
- export function synapEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
79
- return Boolean(env.SYNAP_API_KEY);
80
- }
81
-
82
- /**
83
- * Identity for memory scoping. The git committer email is the natural stable
84
- * user id here: it is already the identity every run is attributed to.
85
- */
86
- async function resolveUserId(repoRoot: string, env: NodeJS.ProcessEnv): Promise<string> {
87
- if (env.SYNAP_USER_ID) return env.SYNAP_USER_ID;
88
- try {
89
- const { stdout } = await execa('git', ['config', 'user.email'], { cwd: repoRoot });
90
- if (stdout.trim()) return stdout.trim();
91
- } catch {
92
- // not configured; fall through
93
- }
94
- return 'copperhead-local';
95
- }
96
-
97
- function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> {
98
- return new Promise<T>((resolve, reject) => {
99
- const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
100
- p.then(
101
- (v) => {
102
- clearTimeout(timer);
103
- resolve(v);
104
- },
105
- (e) => {
106
- clearTimeout(timer);
107
- reject(e);
108
- },
109
- );
110
- });
111
- }
112
-
113
- /**
114
- * Returns null when memory is disabled or unavailable — every caller treats
115
- * null as "run without cross-run memory".
116
- */
117
- export async function openSynapMemory(opts: {
118
- repoRoot: string;
119
- log?: (line: string) => void;
120
- env?: NodeJS.ProcessEnv;
121
- }): Promise<SynapMemory | null> {
122
- const env = opts.env ?? process.env;
123
- const log = opts.log ?? (() => {});
124
- if (!synapEnabled(env)) return null;
125
-
126
- let client: SynapClientLike;
127
- try {
128
- // Non-literal specifier: keeps tsc from resolving an optional dependency at
129
- // build time, so the package may legitimately be absent.
130
- const specifier = '@maximem/synap-js-sdk';
131
- const mod = (await import(specifier)) as {
132
- createClient?: (o?: Record<string, unknown>) => SynapClientLike;
133
- default?: { createClient?: (o?: Record<string, unknown>) => SynapClientLike };
134
- };
135
- const createClient = mod.createClient ?? mod.default?.createClient;
136
- if (!createClient) throw new Error('createClient not exported');
137
- client = createClient({ apiKey: env.SYNAP_API_KEY, requestTimeoutMs: RECORD_TIMEOUT_MS });
138
- await withTimeout(client.init(), RECORD_TIMEOUT_MS, 'synap init');
139
- } catch (err) {
140
- // Missing package, missing Python runtime, bad key: all non-fatal.
141
- log(`synap memory unavailable (${(err as Error).message}); continuing without it`);
142
- return null;
143
- }
144
-
145
- const userId = await resolveUserId(opts.repoRoot, env);
146
- const customerId = env.SYNAP_CUSTOMER_ID ?? 'copperhead';
147
- const conversationId = randomUUID();
148
-
149
- return {
150
- async recall(request) {
151
- try {
152
- const res = await withTimeout(
153
- client.searchMemory({
154
- userId,
155
- customerId,
156
- query: redactSecrets(request),
157
- maxResults: MAX_RECALLED,
158
- }),
159
- RECALL_TIMEOUT_MS,
160
- 'synap recall',
161
- );
162
- const items = (res.results ?? []).filter((r) => r.memory?.trim());
163
- if (!items.length) return null;
164
- const lines = items.map((r) => `- ${r.memory.trim()}${r.contextType ? ` _(${r.contextType})_` : ''}`);
165
- return [
166
- '## Recalled from prior runs (Synap)',
167
- '',
168
- 'Context from earlier work by this user, possibly on other boards. Advisory only:',
169
- 'the KiCad files and this repo’s docs remain the source of truth. If any of this',
170
- 'conflicts with what you read in the repo, the repo wins — say so rather than',
171
- 'acting on a stale memory.',
172
- '',
173
- ...lines,
174
- ].join('\n');
175
- } catch (err) {
176
- log(`synap recall failed (${(err as Error).message}); continuing without it`);
177
- return null;
178
- }
179
- },
180
-
181
- async record(run) {
182
- const assistant = [
183
- `Outcome: ${run.outcome}`,
184
- `Summary: ${run.summary}`,
185
- `OpenSpec change: ${run.changeId ?? 'n/a'}`,
186
- `Verification: ${run.verification}`,
187
- run.filesTouched.length ? `Files: ${run.filesTouched.join(', ')}` : null,
188
- run.decisions.length ? `Decisions:\n${run.decisions.map((d) => `- ${d}`).join('\n')}` : null,
189
- ]
190
- .filter(Boolean)
191
- .join('\n');
192
-
193
- await withTimeout(
194
- client.addMemory({
195
- userId,
196
- customerId,
197
- conversationId,
198
- messages: [
199
- { role: 'user', content: redactSecrets(run.request) },
200
- { role: 'assistant', content: redactSecrets(assistant) },
201
- ],
202
- metadata: { source: 'copperhead', outcome: run.outcome, changeId: run.changeId },
203
- }),
204
- RECORD_TIMEOUT_MS,
205
- 'synap record',
206
- );
207
- },
208
-
209
- async close() {
210
- try {
211
- await withTimeout(client.shutdown(), RECORD_TIMEOUT_MS, 'synap shutdown');
212
- } catch {
213
- // Best effort: a failed shutdown must not change the run's outcome.
214
- }
215
- },
216
- };
217
- }