traceforge-pcb 1.0.0__py3-none-any.whl

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 (219) hide show
  1. traceforge/__init__.py +3 -0
  2. traceforge/__main__.py +3 -0
  3. traceforge/acp_server.py +570 -0
  4. traceforge/agent.py +680 -0
  5. traceforge/altium/__init__.py +6 -0
  6. traceforge/altium/cfb.py +70 -0
  7. traceforge/altium/cli.py +60 -0
  8. traceforge/altium/model.py +163 -0
  9. traceforge/altium/outputs.py +24 -0
  10. traceforge/altium/pcb.py +301 -0
  11. traceforge/altium/project.py +328 -0
  12. traceforge/altium/schematic.py +400 -0
  13. traceforge/altium/tools.py +166 -0
  14. traceforge/approval.py +219 -0
  15. traceforge/attachments.py +346 -0
  16. traceforge/background.py +253 -0
  17. traceforge/bench/__init__.py +5 -0
  18. traceforge/bench/adapters.py +337 -0
  19. traceforge/bench/checks.py +410 -0
  20. traceforge/bench/cli.py +121 -0
  21. traceforge/bench/design.py +439 -0
  22. traceforge/bench/kicad.py +99 -0
  23. traceforge/bench/partscheck.py +159 -0
  24. traceforge/bench/review.py +100 -0
  25. traceforge/bench/runner.py +226 -0
  26. traceforge/bench/score.py +204 -0
  27. traceforge/bench/tasks.py +102 -0
  28. traceforge/chat.py +1185 -0
  29. traceforge/checks.py +328 -0
  30. traceforge/checks_sides.py +118 -0
  31. traceforge/cli.py +2224 -0
  32. traceforge/clipboard.py +107 -0
  33. traceforge/config.py +299 -0
  34. traceforge/context.py +276 -0
  35. traceforge/design/__init__.py +1 -0
  36. traceforge/design/constraints.py +397 -0
  37. traceforge/design/generate.py +1789 -0
  38. traceforge/design/outline.py +905 -0
  39. traceforge/design/schematic_layout.py +932 -0
  40. traceforge/design/schematic_lint.py +329 -0
  41. traceforge/design/schematic_route.py +207 -0
  42. traceforge/design/sheets.py +854 -0
  43. traceforge/design/sides.py +328 -0
  44. traceforge/design/spec.py +462 -0
  45. traceforge/design/specsheet.py +2351 -0
  46. traceforge/design/specsheet_render.py +183 -0
  47. traceforge/desktop/__init__.py +109 -0
  48. traceforge/desktop/keys.py +90 -0
  49. traceforge/desktop/project.py +443 -0
  50. traceforge/desktop/review.py +20 -0
  51. traceforge/desktop/server.py +1277 -0
  52. traceforge/desktop/static/app.css +223 -0
  53. traceforge/desktop/static/app.js +683 -0
  54. traceforge/desktop/static/highlight.js +77 -0
  55. traceforge/desktop/static/index.html +89 -0
  56. traceforge/desktop/static/md.js +90 -0
  57. traceforge/desktop/static/viewer.js +213 -0
  58. traceforge/desktop/symbols.py +212 -0
  59. traceforge/desktop/update.py +65 -0
  60. traceforge/diagnostics.py +67 -0
  61. traceforge/errata.py +219 -0
  62. traceforge/fab.py +108 -0
  63. traceforge/fab_layers.py +44 -0
  64. traceforge/features/__init__.py +6 -0
  65. traceforge/features/candidates.py +117 -0
  66. traceforge/features/cli.py +200 -0
  67. traceforge/features/package.py +433 -0
  68. traceforge/features/revdiff.py +224 -0
  69. traceforge/features/sarif.py +145 -0
  70. traceforge/features/viewer.py +265 -0
  71. traceforge/firmware/__init__.py +3 -0
  72. traceforge/firmware/cli.py +174 -0
  73. traceforge/firmware/drivers.py +372 -0
  74. traceforge/firmware/drivers_data.py +256 -0
  75. traceforge/firmware/mcus.py +177 -0
  76. traceforge/firmware/netlist.py +55 -0
  77. traceforge/firmware/pinmap.py +296 -0
  78. traceforge/firmware/scaffold.py +466 -0
  79. traceforge/firmware/toolchain.py +262 -0
  80. traceforge/hooks.py +237 -0
  81. traceforge/kicad/__init__.py +0 -0
  82. traceforge/kicad/cli.py +519 -0
  83. traceforge/kicad/diffpair.py +174 -0
  84. traceforge/kicad/edgegeom.py +191 -0
  85. traceforge/kicad/edit.py +405 -0
  86. traceforge/kicad/escape.py +1410 -0
  87. traceforge/kicad/formats.py +1073 -0
  88. traceforge/kicad/impedance.py +320 -0
  89. traceforge/kicad/install.py +75 -0
  90. traceforge/kicad/libraries.py +490 -0
  91. traceforge/kicad/models3d.py +942 -0
  92. traceforge/kicad/outputs.py +81 -0
  93. traceforge/kicad/pcb.py +787 -0
  94. traceforge/kicad/planes.py +1242 -0
  95. traceforge/kicad/project.py +136 -0
  96. traceforge/kicad/route.py +2698 -0
  97. traceforge/kicad/routegeom.py +164 -0
  98. traceforge/kicad/schematic.py +268 -0
  99. traceforge/kicad/sides.py +34 -0
  100. traceforge/kicad/stackup.py +424 -0
  101. traceforge/kicad_plugin/__init__.py +12 -0
  102. traceforge/kicad_plugin/actions.py +92 -0
  103. traceforge/kicad_plugin/cli.py +66 -0
  104. traceforge/kicad_plugin/client.py +86 -0
  105. traceforge/kicad_plugin/context.py +257 -0
  106. traceforge/kicad_plugin/discovery.py +81 -0
  107. traceforge/kicad_plugin/install.py +203 -0
  108. traceforge/kicad_plugin/overlay.py +60 -0
  109. traceforge/kicad_plugin/payload/action_plugin.py +65 -0
  110. traceforge/kicad_plugin/payload/ipc_main.py +42 -0
  111. traceforge/kicad_plugin/payload/plugin.json +90 -0
  112. traceforge/kicad_plugin/payload/requirements.txt +1 -0
  113. traceforge/kicad_plugin/views.py +202 -0
  114. traceforge/langchain.py +57 -0
  115. traceforge/local.py +580 -0
  116. traceforge/mcp_server.py +304 -0
  117. traceforge/models.py +310 -0
  118. traceforge/next_steps.py +110 -0
  119. traceforge/parts/__init__.py +52 -0
  120. traceforge/parts/alternates.py +488 -0
  121. traceforge/parts/bom.py +423 -0
  122. traceforge/parts/brief.py +705 -0
  123. traceforge/parts/catalog.py +297 -0
  124. traceforge/parts/cli_sourcing.py +156 -0
  125. traceforge/parts/data/duty_rates.yaml +37 -0
  126. traceforge/parts/data/hs_codes.yaml +103 -0
  127. traceforge/parts/datasheet.py +278 -0
  128. traceforge/parts/fx.py +100 -0
  129. traceforge/parts/model.py +185 -0
  130. traceforge/parts/net.py +187 -0
  131. traceforge/parts/offers.py +46 -0
  132. traceforge/parts/packages.py +397 -0
  133. traceforge/parts/pinout.py +714 -0
  134. traceforge/parts/providers/__init__.py +17 -0
  135. traceforge/parts/providers/base.py +100 -0
  136. traceforge/parts/providers/jlc.py +364 -0
  137. traceforge/parts/providers/keyed.py +661 -0
  138. traceforge/parts/report.py +508 -0
  139. traceforge/parts/supply.py +330 -0
  140. traceforge/parts/verify.py +455 -0
  141. traceforge/parts/web.py +575 -0
  142. traceforge/plans.py +70 -0
  143. traceforge/preferences.py +127 -0
  144. traceforge/prices.py +130 -0
  145. traceforge/prices.yaml +28 -0
  146. traceforge/prompts/__init__.py +54 -0
  147. traceforge/prompts/system.md +103 -0
  148. traceforge/providers.py +764 -0
  149. traceforge/remote/__init__.py +33 -0
  150. traceforge/remote/client.py +422 -0
  151. traceforge/remote/oidc.py +240 -0
  152. traceforge/remote/orgs.py +510 -0
  153. traceforge/remote/server.py +690 -0
  154. traceforge/remote/store.py +100 -0
  155. traceforge/review_changes.py +187 -0
  156. traceforge/routing.py +99 -0
  157. traceforge/runtime.py +435 -0
  158. traceforge/sessions.py +324 -0
  159. traceforge/sexpr.py +279 -0
  160. traceforge/sim/__init__.py +17 -0
  161. traceforge/sim/acmath.py +234 -0
  162. traceforge/sim/circuit.py +499 -0
  163. traceforge/sim/cli.py +162 -0
  164. traceforge/sim/engine.py +172 -0
  165. traceforge/sim/ltspice.py +175 -0
  166. traceforge/sim/measure.py +123 -0
  167. traceforge/sim/models.py +76 -0
  168. traceforge/sim/project.py +218 -0
  169. traceforge/sim/rawfile.py +123 -0
  170. traceforge/sim/results.py +298 -0
  171. traceforge/sim/tune.py +172 -0
  172. traceforge/skill_commands.py +165 -0
  173. traceforge/skill_library/circuit-simulation/SKILL.md +30 -0
  174. traceforge/skill_library/compliance-review/SKILL.md +51 -0
  175. traceforge/skill_library/design-review/SKILL.md +47 -0
  176. traceforge/skill_library/errata-hunt/SKILL.md +30 -0
  177. traceforge/skill_library/firmware/SKILL.md +26 -0
  178. traceforge/skill_library/power-budget/SKILL.md +27 -0
  179. traceforge/skill_library/release-package/SKILL.md +29 -0
  180. traceforge/skill_library/sourcing-brief/SKILL.md +24 -0
  181. traceforge/skill_library/thermal-review/SKILL.md +15 -0
  182. traceforge/skills.py +131 -0
  183. traceforge/thermal.py +557 -0
  184. traceforge/tools/__init__.py +48 -0
  185. traceforge/tools/agent_tools.py +213 -0
  186. traceforge/tools/base.py +303 -0
  187. traceforge/tools/batch.py +155 -0
  188. traceforge/tools/connections.py +245 -0
  189. traceforge/tools/files.py +777 -0
  190. traceforge/tools/firmware_tools.py +218 -0
  191. traceforge/tools/kicad_tools.py +1072 -0
  192. traceforge/tools/models3d_tools.py +141 -0
  193. traceforge/tools/parts_tools.py +473 -0
  194. traceforge/tools/processes.py +246 -0
  195. traceforge/tools/review_tools.py +178 -0
  196. traceforge/tools/sim_tools.py +230 -0
  197. traceforge/tui.py +620 -0
  198. traceforge/updates.py +124 -0
  199. traceforge/validate/__init__.py +77 -0
  200. traceforge/validate/builtins.py +1091 -0
  201. traceforge/validate/circuits.py +1240 -0
  202. traceforge/validate/config.py +410 -0
  203. traceforge/validate/default.yaml +113 -0
  204. traceforge/validate/design.py +191 -0
  205. traceforge/validate/explain.py +112 -0
  206. traceforge/validate/grounding.py +169 -0
  207. traceforge/validate/ipc2221.py +350 -0
  208. traceforge/validate/multilayer.py +805 -0
  209. traceforge/validate/netmodel.py +261 -0
  210. traceforge/validate/partcheck.py +472 -0
  211. traceforge/validate/report.py +280 -0
  212. traceforge/validate/review.py +322 -0
  213. traceforge/validate/runner.py +662 -0
  214. traceforge/workspace.py +171 -0
  215. traceforge_pcb-1.0.0.dist-info/METADATA +463 -0
  216. traceforge_pcb-1.0.0.dist-info/RECORD +219 -0
  217. traceforge_pcb-1.0.0.dist-info/WHEEL +4 -0
  218. traceforge_pcb-1.0.0.dist-info/entry_points.txt +2 -0
  219. traceforge_pcb-1.0.0.dist-info/licenses/LICENSE +21 -0
traceforge/approval.py ADDED
@@ -0,0 +1,219 @@
1
+ """Decide which tool calls run without asking the user."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import re
7
+ from collections.abc import Awaitable, Callable
8
+ from dataclasses import dataclass
9
+ from enum import StrEnum
10
+ from pathlib import PurePosixPath
11
+ from typing import Any
12
+
13
+ from traceforge.tools.base import Tool, ToolKind
14
+ from traceforge.tools.files import dangerous_command, simple_command
15
+
16
+
17
+ class ApprovalMode(StrEnum):
18
+ ASK = "ask" # ask before any write or shell command
19
+ AUTO = "auto" # writes and simple commands run; dangerous commands ask
20
+ YOLO = "yolo" # everything runs (still blocks the destructive patterns when no approver)
21
+ READ_ONLY = "read-only" # writes and shell are rejected
22
+
23
+
24
+ class Decision(StrEnum):
25
+ ALLOW = "allow"
26
+ ASK = "ask"
27
+ DENY = "deny"
28
+
29
+
30
+ @dataclass
31
+ class ApprovalRequest:
32
+ tool: str
33
+ kind: ToolKind
34
+ arguments: dict[str, Any]
35
+ summary: str
36
+ dangerous: bool = False
37
+
38
+
39
+ @dataclass
40
+ class ApprovalAnswer:
41
+ """A richer answer than yes/no from an interactive approver."""
42
+
43
+ approved: bool
44
+ scope: str = "once" # once | tool (this tool, rest of the session) | auto (switch to auto)
45
+ reason: str = ""
46
+
47
+
48
+ Approver = Callable[[ApprovalRequest], Awaitable["bool | ApprovalAnswer"]]
49
+
50
+ AUTO_SHELL_LEVELS = ("simple", "any", "dangerous")
51
+
52
+ # Files that change what traceforge or other tools execute later. Auto mode still asks
53
+ # before writing them, so an agent cannot quietly install a hook or a CI job.
54
+ _SENSITIVE_WRITE = re.compile(
55
+ r"(^|/)(\.git|\.github|\.gitlab-ci\.yml|\.circleci|\.husky|\.vscode|\.devcontainer|\.ssh)(/|$)"
56
+ r"|(^|/)\.traceforge/(hooks\.(ya?ml|json)|config\.ya?ml|skills/)"
57
+ r"|(^|/)(\.env[^/]*|\.bashrc|\.zshrc|\.profile|\.mcp\.json|setup\.py|conftest\.py|"
58
+ r"sitecustomize\.py|Makefile|Dockerfile|docker-compose\.ya?ml|\.pre-commit-config\.yaml)$",
59
+ re.IGNORECASE,
60
+ )
61
+
62
+
63
+ def sensitive_write(path: str) -> bool:
64
+ text = PurePosixPath(str(path).replace("\\", "/")).as_posix()
65
+ return bool(_SENSITIVE_WRITE.search(text))
66
+
67
+
68
+ # In auto mode, writes to these still ask: they configure tools, CI, hooks or the
69
+ # environment, so a bad write there runs code or changes more than the design.
70
+ _SENSITIVE_DIRS = {
71
+ ".git",
72
+ ".github",
73
+ ".gitlab",
74
+ ".circleci",
75
+ ".husky",
76
+ ".vscode",
77
+ ".devcontainer",
78
+ ".claude",
79
+ ".cursor",
80
+ ".ssh",
81
+ "hooks",
82
+ }
83
+ _SENSITIVE_FILES = {
84
+ ".bashrc",
85
+ ".zshrc",
86
+ ".profile",
87
+ ".bash_profile",
88
+ ".gitattributes",
89
+ ".gitmodules",
90
+ ".mcp.json",
91
+ ".pre-commit-config.yaml",
92
+ "makefile",
93
+ "dockerfile",
94
+ "docker-compose.yml",
95
+ "docker-compose.yaml",
96
+ "pyproject.toml",
97
+ "setup.py",
98
+ "setup.cfg",
99
+ "requirements.txt",
100
+ "package.json",
101
+ "conftest.py",
102
+ "sitecustomize.py",
103
+ "claude.md",
104
+ "agents.md",
105
+ "traceforge.md",
106
+ }
107
+ _SCRIPT_SUFFIXES = {".sh", ".bash", ".zsh", ".fish", ".ps1", ".bat", ".cmd", ".command"}
108
+ _FILE_TOOLS = {"write_file", "edit_file", "multi_edit", "write_binary_file", "delete_file"}
109
+
110
+
111
+ def sensitive_path(raw: Any) -> bool:
112
+ """True for paths whose change needs a person even in auto mode."""
113
+ if not isinstance(raw, str) or not raw.strip():
114
+ return False
115
+ parts = [part.lower() for part in raw.replace("\\", "/").split("/") if part not in ("", ".")]
116
+ if not parts:
117
+ return False
118
+ if any(part in _SENSITIVE_DIRS for part in parts[:-1]):
119
+ return True
120
+ name = parts[-1]
121
+ if parts[:2] == [".traceforge", "hooks.yaml"] or name in {"hooks.yaml", "hooks.json"}:
122
+ return True
123
+ return name in _SENSITIVE_FILES or any(name.endswith(s) for s in _SCRIPT_SUFFIXES)
124
+
125
+
126
+ def summarize_call(tool: Tool, arguments: dict[str, Any]) -> str:
127
+ if tool.name == "execute":
128
+ prefix = "(background) " if arguments.get("background") else ""
129
+ return f"{prefix}$ {arguments.get('command', '')}"
130
+ if tool.name == "delete_file":
131
+ recursive = " (recursive)" if arguments.get("recursive") else ""
132
+ return f"delete {arguments.get('path', '')}{recursive}"
133
+ if tool.name in _FILE_TOOLS:
134
+ return f"{tool.name} {arguments.get('path', '')}"
135
+ target = arguments.get(tool.target_arg) if tool.target_arg else None
136
+ return f"{tool.name}" + (f" {target}" if target else "")
137
+
138
+
139
+ def decide(
140
+ mode: ApprovalMode, tool: Tool, arguments: dict[str, Any], auto_shell: str = "any"
141
+ ) -> Decision:
142
+ if tool.kind in {ToolKind.READ, ToolKind.INTERACTIVE}:
143
+ return Decision.ALLOW
144
+ if mode is ApprovalMode.READ_ONLY:
145
+ return Decision.DENY
146
+ dangerous = tool.kind is ToolKind.SHELL and dangerous_command(str(arguments.get("command", "")))
147
+ if mode is ApprovalMode.YOLO:
148
+ return Decision.ASK if dangerous else Decision.ALLOW
149
+ if mode is ApprovalMode.AUTO:
150
+ if tool.name == "delete_file" and arguments.get("recursive"):
151
+ return Decision.ASK
152
+ if tool.name in _FILE_TOOLS and sensitive_path(arguments.get("path")):
153
+ return Decision.ASK
154
+ if tool.kind is ToolKind.WRITE:
155
+ target = arguments.get(tool.target_arg or "path") if tool.target_arg else None
156
+ return Decision.ASK if target and sensitive_write(str(target)) else Decision.ALLOW
157
+ if dangerous:
158
+ return Decision.ALLOW if auto_shell == "dangerous" else Decision.ASK
159
+ if auto_shell in {"any", "dangerous"}:
160
+ return Decision.ALLOW
161
+ return Decision.ALLOW if simple_command(str(arguments.get("command", ""))) else Decision.ASK
162
+ return Decision.ASK
163
+
164
+
165
+ class ApprovalPolicy:
166
+ def __init__(
167
+ self,
168
+ mode: ApprovalMode | str = ApprovalMode.ASK,
169
+ approver: Approver | None = None,
170
+ auto_shell: str = "any",
171
+ ):
172
+ self.mode = ApprovalMode(mode)
173
+ self.approver = approver
174
+ self.auto_shell = auto_shell if auto_shell in AUTO_SHELL_LEVELS else "any"
175
+ self._always: set[str] = set()
176
+ # Tool calls of one model step can run concurrently; questions to the user must not.
177
+ self._prompt_lock = asyncio.Lock()
178
+
179
+ def allow_tool_for_session(self, tool_name: str) -> None:
180
+ self._always.add(tool_name)
181
+
182
+ async def check(self, tool: Tool, arguments: dict[str, Any]) -> tuple[bool, str]:
183
+ decision = decide(self.mode, tool, arguments, self.auto_shell)
184
+ if decision is Decision.ALLOW:
185
+ return True, ""
186
+ if decision is Decision.DENY:
187
+ return False, f"{tool.name} is not allowed in {self.mode.value} mode"
188
+ dangerous = tool.kind is ToolKind.SHELL and dangerous_command(
189
+ str(arguments.get("command", ""))
190
+ )
191
+ if tool.name in self._always and not dangerous:
192
+ return True, ""
193
+ if self.approver is None:
194
+ return False, (
195
+ f"{tool.name} needs approval and no one is available to approve it. "
196
+ "Use a read-only approach, or ask the user to rerun with --approve auto."
197
+ )
198
+ request = ApprovalRequest(
199
+ tool.name, tool.kind, arguments, summarize_call(tool, arguments), dangerous
200
+ )
201
+ async with self._prompt_lock:
202
+ if tool.name in self._always and not dangerous:
203
+ return True, "" # approved "for the session" while this call waited
204
+ answer = await self.approver(request)
205
+ if isinstance(answer, ApprovalAnswer):
206
+ if answer.approved and answer.scope == "tool":
207
+ self.allow_tool_for_session(tool.name)
208
+ elif answer.approved and answer.scope == "auto" and self.mode is ApprovalMode.ASK:
209
+ self.mode = ApprovalMode.AUTO
210
+ if isinstance(answer, ApprovalAnswer):
211
+ if answer.approved:
212
+ return True, ""
213
+ reason = answer.reason.strip()
214
+ return False, (
215
+ f"the user declined this action: {reason}"
216
+ if reason
217
+ else "the user declined this action"
218
+ )
219
+ return (True, "") if answer else (False, "the user declined this action")
@@ -0,0 +1,346 @@
1
+ """Images, screenshots, file mentions and pasted KiCad snippets in a user prompt.
2
+
3
+ * Images (a photo of a board, a datasheet figure, a screenshot of KiCad) are sent to
4
+ the model as image content blocks. They come from ``@path`` mentions, the
5
+ ``/attach`` command, the clipboard (``/paste``, Ctrl+V in the terminal UI) or an
6
+ ACP client.
7
+ * ``@path`` mentions of other files are listed for the agent, which reads them with
8
+ its file tools, so large files never inflate the prompt.
9
+ * A pasted KiCad S-expression (a footprint, a symbol, part of a board) is recognised
10
+ and wrapped in ``<kicad_snippet>`` so the agent knows what it is looking at.
11
+
12
+ The content blocks use the Anthropic Messages shape; the OpenAI adapter converts
13
+ them (see :func:`traceforge.providers.to_openai_input`).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ import re
20
+ import shutil
21
+ import subprocess
22
+ import sys
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ MAX_IMAGE_BYTES = 5 * 1024 * 1024 # the per-image limit of the Messages API
28
+ MAX_IMAGES = 20
29
+ MAX_SNIPPET_CHARS = 1_000_000
30
+ IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
31
+ KICAD_HEADS = (
32
+ "kicad_pcb",
33
+ "kicad_sch",
34
+ "kicad_symbol_lib",
35
+ "footprint",
36
+ "module",
37
+ "symbol",
38
+ "lib_symbols",
39
+ "segment",
40
+ "via",
41
+ "zone",
42
+ "net",
43
+ "wire",
44
+ "junction",
45
+ "label",
46
+ "global_label",
47
+ "hierarchical_label",
48
+ "gr_line",
49
+ "gr_rect",
50
+ "gr_text",
51
+ "fp_line",
52
+ "fp_text",
53
+ "pad",
54
+ "property",
55
+ "net_class",
56
+ )
57
+ _MENTION = re.compile(r"(?<![\w@])@((?:\\ |[^\s@])+)")
58
+ _HEAD = re.compile(r"\(\s*(" + "|".join(KICAD_HEADS) + r")[\s)]")
59
+
60
+
61
+ class AttachmentError(ValueError):
62
+ """An attachment could not be used."""
63
+
64
+
65
+ def sniff_image_type(data: bytes) -> str | None:
66
+ """Media type from the file signature (not the name)."""
67
+ if data.startswith(b"\x89PNG\r\n\x1a\n"):
68
+ return "image/png"
69
+ if data.startswith(b"\xff\xd8\xff"):
70
+ return "image/jpeg"
71
+ if data[:6] in (b"GIF87a", b"GIF89a"):
72
+ return "image/gif"
73
+ if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
74
+ return "image/webp"
75
+ return None
76
+
77
+
78
+ @dataclass
79
+ class ImageAttachment:
80
+ data: bytes
81
+ media_type: str
82
+ name: str = "image"
83
+
84
+ @classmethod
85
+ def from_bytes(cls, data: bytes, name: str = "image") -> ImageAttachment:
86
+ if not data:
87
+ raise AttachmentError(f"{name} is empty")
88
+ media_type = sniff_image_type(data)
89
+ if media_type is None:
90
+ raise AttachmentError(f"{name} is not a PNG, JPEG, GIF or WebP image")
91
+ if len(data) > MAX_IMAGE_BYTES:
92
+ data, media_type = _shrink(data, name)
93
+ return cls(data, media_type, name)
94
+
95
+ @classmethod
96
+ def from_path(cls, path: Path) -> ImageAttachment:
97
+ try:
98
+ data = Path(path).read_bytes()
99
+ except OSError as exc:
100
+ raise AttachmentError(f"cannot read {path}: {exc}") from exc
101
+ return cls.from_bytes(data, Path(path).name)
102
+
103
+ @classmethod
104
+ def from_base64(cls, payload: str, name: str = "image") -> ImageAttachment:
105
+ try:
106
+ data = base64.b64decode(payload, validate=True)
107
+ except ValueError as exc:
108
+ raise AttachmentError(f"{name} is not valid base64") from exc
109
+ return cls.from_bytes(data, name)
110
+
111
+ def block(self) -> dict[str, Any]:
112
+ return {
113
+ "type": "image",
114
+ "source": {
115
+ "type": "base64",
116
+ "media_type": self.media_type,
117
+ "data": base64.b64encode(self.data).decode("ascii"),
118
+ },
119
+ }
120
+
121
+ def describe(self) -> str:
122
+ return f"{self.name} ({self.media_type}, {len(self.data) // 1024} KB)"
123
+
124
+
125
+ def _shrink(data: bytes, name: str) -> tuple[bytes, str]:
126
+ """Downscale an oversized image when Pillow is available."""
127
+ try:
128
+ import io
129
+
130
+ from PIL import Image
131
+ except ImportError:
132
+ raise AttachmentError(
133
+ f"{name} is {len(data) // (1024 * 1024)} MB; images must be under 5 MB "
134
+ "(install Pillow to have large images scaled down automatically)"
135
+ ) from None
136
+ with Image.open(io.BytesIO(data)) as image:
137
+ image = image.convert("RGB")
138
+ scale = 1.0
139
+ while True:
140
+ scale *= 0.75
141
+ size = (max(1, int(image.width * scale)), max(1, int(image.height * scale)))
142
+ out = io.BytesIO()
143
+ image.resize(size).save(out, format="JPEG", quality=85)
144
+ if out.tell() <= MAX_IMAGE_BYTES or scale < 0.05:
145
+ return out.getvalue(), "image/jpeg"
146
+
147
+
148
+ # ------------------------------------------------------------------ clipboard
149
+
150
+
151
+ def clipboard_image() -> ImageAttachment | None:
152
+ """An image from the system clipboard, or None when there is none."""
153
+ commands: list[list[str]] = []
154
+ if sys.platform == "darwin":
155
+ commands.append(["pngpaste", "-"])
156
+ elif sys.platform.startswith("linux"):
157
+ commands.append(["wl-paste", "--no-newline", "--type", "image/png"])
158
+ commands.append(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"])
159
+ elif sys.platform == "win32": # pragma: no cover
160
+ commands.append(
161
+ [
162
+ "powershell",
163
+ "-NoProfile",
164
+ "-Command",
165
+ "Add-Type -AssemblyName System.Windows.Forms;"
166
+ "$i=[Windows.Forms.Clipboard]::GetImage();if($i){$m=New-Object IO.MemoryStream;"
167
+ "$i.Save($m,[Drawing.Imaging.ImageFormat]::Png);"
168
+ "[Console]::OpenStandardOutput().Write($m.ToArray(),0,$m.Length)}",
169
+ ]
170
+ )
171
+ for command in commands:
172
+ if shutil.which(command[0]) is None:
173
+ continue
174
+ try:
175
+ result = subprocess.run(command, capture_output=True, timeout=3, check=False)
176
+ except (OSError, subprocess.TimeoutExpired):
177
+ continue
178
+ if result.returncode == 0 and sniff_image_type(result.stdout):
179
+ try:
180
+ return ImageAttachment.from_bytes(result.stdout, "clipboard.png")
181
+ except AttachmentError:
182
+ return None
183
+ if sys.platform == "darwin":
184
+ return _macos_clipboard_image()
185
+ return None
186
+
187
+
188
+ def _macos_clipboard_image() -> ImageAttachment | None: # pragma: no cover - macOS only
189
+ import tempfile
190
+
191
+ if shutil.which("osascript") is None:
192
+ return None
193
+ with tempfile.TemporaryDirectory() as tmp:
194
+ target = Path(tmp) / "clip.png"
195
+ script = (
196
+ "set png to the clipboard as «class PNGf»\n"
197
+ f'set f to open for access POSIX file "{target}" with write permission\n'
198
+ "write png to f\nclose access f"
199
+ )
200
+ try:
201
+ subprocess.run(["osascript", "-e", script], capture_output=True, timeout=3, check=False)
202
+ if target.exists():
203
+ return ImageAttachment.from_path(target)
204
+ except (OSError, subprocess.TimeoutExpired, AttachmentError):
205
+ return None
206
+ return None
207
+
208
+
209
+ # --------------------------------------------------------------- KiCad snippets
210
+
211
+
212
+ def find_kicad_snippet(text: str) -> tuple[int, int] | None:
213
+ """Span of the first balanced KiCad S-expression in ``text``, if any."""
214
+ if len(text) > MAX_SNIPPET_CHARS:
215
+ return None
216
+ for match in _HEAD.finditer(text):
217
+ start = match.start()
218
+ end = _balanced_end(text, start)
219
+ if end is not None:
220
+ return start, end
221
+ return None
222
+
223
+
224
+ def _balanced_end(text: str, start: int) -> int | None:
225
+ depth = 0
226
+ quoted = escaped = False
227
+ for index in range(start, len(text)):
228
+ char = text[index]
229
+ if escaped:
230
+ escaped = False
231
+ elif quoted and char == "\\":
232
+ escaped = True
233
+ elif char == '"':
234
+ quoted = not quoted
235
+ elif not quoted and char == "(":
236
+ depth += 1
237
+ elif not quoted and char == ")":
238
+ depth -= 1
239
+ if depth == 0:
240
+ return index + 1
241
+ return None
242
+
243
+
244
+ def looks_like_kicad_snippet(text: str) -> bool:
245
+ """True when the whole text is one KiCad S-expression."""
246
+ stripped = text.strip()
247
+ span = find_kicad_snippet(stripped)
248
+ return span is not None and span == (0, len(stripped))
249
+
250
+
251
+ def split_kicad_snippet(text: str) -> tuple[str, str | None]:
252
+ """(the user's own words, the snippet) for a prompt that contains a pasted snippet.
253
+
254
+ Only multi-line expressions count, so a short inline mention like ``(net GND)`` in a
255
+ sentence stays part of the sentence.
256
+ """
257
+ span = find_kicad_snippet(text)
258
+ if span is None:
259
+ return text, None
260
+ snippet = text[span[0] : span[1]]
261
+ if "\n" not in snippet and len(snippet) < 200:
262
+ return text, None
263
+ prose = (text[: span[0]] + text[span[1] :]).strip()
264
+ return prose, snippet
265
+
266
+
267
+ # --------------------------------------------------------------------- prompts
268
+
269
+
270
+ @dataclass
271
+ class PreparedPrompt:
272
+ """A user prompt turned into content blocks."""
273
+
274
+ text: str
275
+ images: list[ImageAttachment] = field(default_factory=list)
276
+ mentioned: list[str] = field(default_factory=list)
277
+ snippet: str | None = None
278
+ problems: list[str] = field(default_factory=list)
279
+
280
+ def blocks(self) -> list[dict[str, Any]]:
281
+ parts: list[str] = []
282
+ text = self.text.strip()
283
+ if self.snippet is not None:
284
+ parts.append(
285
+ "The user pasted this KiCad S-expression snippet:\n<kicad_snippet>\n"
286
+ f"{self.snippet.strip()}\n</kicad_snippet>"
287
+ )
288
+ text = text or "Explain this KiCad snippet and point out anything wrong with it."
289
+ if self.mentioned:
290
+ listing = "\n".join(f"- {path}" for path in self.mentioned)
291
+ parts.append(f"Files the user mentioned (read them as needed):\n{listing}")
292
+ if self.images and not text:
293
+ text = "Look at the attached image(s)."
294
+ blocks: list[dict[str, Any]] = [image.block() for image in self.images[:MAX_IMAGES]]
295
+ body = "\n\n".join([*parts, text]) if parts else text
296
+ if body:
297
+ blocks.append({"type": "text", "text": body})
298
+ return blocks
299
+
300
+ def summary(self) -> str:
301
+ bits = []
302
+ if self.images:
303
+ bits.append(f"{len(self.images)} image(s)")
304
+ if self.mentioned:
305
+ bits.append(f"{len(self.mentioned)} file mention(s)")
306
+ if self.snippet is not None:
307
+ bits.append("a KiCad snippet")
308
+ return ", ".join(bits)
309
+
310
+
311
+ def prepare_prompt(
312
+ text: str, workspace: Path, images: list[ImageAttachment] | None = None
313
+ ) -> PreparedPrompt:
314
+ """Resolve ``@path`` mentions and pasted snippets; ``images`` are already attached."""
315
+ prepared = PreparedPrompt(text, list(images or []))
316
+ root = Path(workspace).resolve()
317
+ prose, snippet = split_kicad_snippet(text)
318
+ if snippet is not None:
319
+ prepared.snippet = snippet
320
+ text = prose
321
+ for match in _MENTION.finditer(text):
322
+ raw = match.group(1).replace("\\ ", " ").rstrip(".,;:)!?")
323
+ candidate = (root / raw).resolve() if not Path(raw).is_absolute() else Path(raw).resolve()
324
+ if not candidate.exists():
325
+ continue
326
+ if candidate.suffix.lower() in IMAGE_SUFFIXES and candidate.is_file():
327
+ try:
328
+ prepared.images.append(ImageAttachment.from_path(candidate))
329
+ except AttachmentError as exc:
330
+ prepared.problems.append(str(exc))
331
+ continue
332
+ try:
333
+ shown = candidate.relative_to(root).as_posix()
334
+ except ValueError:
335
+ prepared.problems.append(f"{raw} is outside the workspace; the agent cannot read it")
336
+ continue
337
+ if shown not in prepared.mentioned:
338
+ prepared.mentioned.append(shown + ("/" if candidate.is_dir() else ""))
339
+ prepared.text = text
340
+ return prepared
341
+
342
+
343
+ def image_blocks_count(content: Any) -> int:
344
+ if not isinstance(content, list):
345
+ return 0
346
+ return sum(1 for block in content if isinstance(block, dict) and block.get("type") == "image")