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
@@ -0,0 +1,777 @@
1
+ """Filesystem and shell tools, confined to the workspace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import base64
7
+ import difflib
8
+ import os
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from traceforge.kicad.project import SKIP_DIRS
17
+ from traceforge.tools.base import Tool, ToolContext, ToolError, ToolKind, ToolOutput
18
+ from traceforge.tools.processes import start_background
19
+
20
+ MAX_READ_LINES = 2000
21
+ MAX_READ_CHARS = 28_000 # one read stays well inside the context budget
22
+ MAX_LINE = 2000
23
+ MAX_FILE_BYTES = 25 * 1024 * 1024
24
+ MAX_LS = 1000
25
+
26
+
27
+ def _iter_files(root: Path, limit: int = 20_000):
28
+ count = 0
29
+ for directory, dirnames, filenames in os.walk(root):
30
+ dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS and not d.startswith("."))
31
+ for filename in sorted(filenames):
32
+ yield Path(directory) / filename
33
+ count += 1
34
+ if count >= limit:
35
+ return
36
+
37
+
38
+ def ls(args: dict[str, Any], ctx: ToolContext) -> str:
39
+ path = ctx.resolve(args.get("path"), must_exist=True)
40
+ if path.is_file():
41
+ return f"{ctx.display(path)} ({path.stat().st_size} bytes)"
42
+ lines = []
43
+ entries = sorted(path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
44
+ for entry in entries:
45
+ if entry.name in SKIP_DIRS and path == ctx.workspace:
46
+ continue
47
+ if entry.is_dir():
48
+ lines.append(f"{entry.name}/")
49
+ else:
50
+ lines.append(f"{entry.name} ({entry.stat().st_size} bytes)")
51
+ if len(lines) >= MAX_LS:
52
+ lines.append(f"... ({len(entries) - MAX_LS} more entries; use glob to narrow)")
53
+ break
54
+ return "\n".join(lines) or "(empty directory)"
55
+
56
+
57
+ # ------------------------------------------------------------------------------ read
58
+
59
+ IMAGE_TYPES = {
60
+ ".png": "image/png",
61
+ ".jpg": "image/jpeg",
62
+ ".jpeg": "image/jpeg",
63
+ ".gif": "image/gif",
64
+ ".webp": "image/webp",
65
+ }
66
+ MAX_IMAGE_BYTES = 3_700_000 # base64 stays under the 5 MB per-image API limit
67
+ MAX_PDF_PAGES = 20
68
+
69
+
70
+ def _convert_available() -> str | None:
71
+ return shutil.which("magick") or shutil.which("convert")
72
+
73
+
74
+ def _image_output(path: Path, ctx: ToolContext, data: bytes | None = None) -> ToolOutput:
75
+ media = IMAGE_TYPES.get(path.suffix.lower(), "image/png")
76
+ data = path.read_bytes() if data is None else data
77
+ note = ""
78
+ if len(data) > MAX_IMAGE_BYTES:
79
+ tool = _convert_available()
80
+ if tool is None:
81
+ raise ToolError(
82
+ f"image is {len(data)} bytes, over the {MAX_IMAGE_BYTES}-byte limit, and "
83
+ "ImageMagick is not installed to shrink it"
84
+ )
85
+ shrunk = subprocess.run(
86
+ [tool, str(path), "-resize", "1568x1568>", "-quality", "85", "jpeg:-"],
87
+ capture_output=True,
88
+ timeout=60,
89
+ check=False,
90
+ )
91
+ if shrunk.returncode != 0 or len(shrunk.stdout) > MAX_IMAGE_BYTES:
92
+ raise ToolError("image is too large to show and could not be shrunk")
93
+ data, media, note = shrunk.stdout, "image/jpeg", " (downscaled)"
94
+ encoded = base64.b64encode(data).decode("ascii")
95
+ return ToolOutput(
96
+ f"{ctx.display(path)}: {media}, {path.stat().st_size} bytes{note}",
97
+ [{"media_type": media, "data": encoded}],
98
+ )
99
+
100
+
101
+ def _svg_as_image(path: Path, ctx: ToolContext) -> ToolOutput:
102
+ tool = _convert_available()
103
+ if tool is None:
104
+ raise ToolError("rendering SVG needs ImageMagick (convert); read it as text instead")
105
+ done = subprocess.run(
106
+ [
107
+ tool,
108
+ "-density",
109
+ "150",
110
+ "-background",
111
+ "white",
112
+ "-flatten",
113
+ str(path),
114
+ "-resize",
115
+ "1568x1568>",
116
+ "png:-",
117
+ ],
118
+ capture_output=True,
119
+ timeout=120,
120
+ check=False,
121
+ )
122
+ if done.returncode != 0 or not done.stdout:
123
+ raise ToolError(f"could not render {ctx.display(path)}: {done.stderr.decode()[:300]}")
124
+ return _image_output(path.with_suffix(".png"), ctx, done.stdout)
125
+
126
+
127
+ def _pdf_text(path: Path, pages: str | None) -> str:
128
+ if shutil.which("pdftotext") is None:
129
+ raise ToolError("reading PDFs needs pdftotext (poppler-utils)")
130
+ first, last = 1, MAX_PDF_PAGES
131
+ if pages:
132
+ match = re.fullmatch(r"\s*(\d+)\s*(?:-\s*(\d+))?\s*", str(pages))
133
+ if not match:
134
+ raise ToolError("pages must look like '3' or '1-5'")
135
+ first = int(match.group(1))
136
+ last = int(match.group(2) or first)
137
+ if last < first:
138
+ raise ToolError("pages: the last page is before the first")
139
+ if last - first + 1 > MAX_PDF_PAGES:
140
+ raise ToolError(f"at most {MAX_PDF_PAGES} pages per read")
141
+ done = subprocess.run(
142
+ ["pdftotext", "-layout", "-f", str(first), "-l", str(last), str(path), "-"],
143
+ capture_output=True,
144
+ timeout=120,
145
+ check=False,
146
+ )
147
+ if done.returncode != 0:
148
+ raise ToolError(f"pdftotext failed: {done.stderr.decode(errors='replace')[:300]}")
149
+ text = done.stdout.decode("utf-8", errors="replace")
150
+ pages_read = text.count("\f") or 1
151
+ return f"[pages {first}-{first + pages_read - 1}]\n" + text.replace("\f", "\n[page break]\n")
152
+
153
+
154
+ def read_file(args: dict[str, Any], ctx: ToolContext) -> str | ToolOutput:
155
+ path = ctx.resolve(args["path"], must_exist=True)
156
+ if path.is_dir():
157
+ raise ToolError(f"{args['path']} is a directory; use ls")
158
+ suffix = path.suffix.lower()
159
+ if suffix in IMAGE_TYPES:
160
+ return _image_output(path, ctx)
161
+ if suffix == ".svg" and args.get("as_image"):
162
+ return _svg_as_image(path, ctx)
163
+ if path.stat().st_size > MAX_FILE_BYTES:
164
+ raise ToolError("file is too large to read; use grep to find the relevant part")
165
+ if suffix == ".pdf":
166
+ text = _pdf_text(path, args.get("pages"))
167
+ else:
168
+ data = path.read_bytes()
169
+ if b"\x00" in data[:8192]:
170
+ raise ToolError(
171
+ f"binary file ({len(data)} bytes); not shown. Images (png, jpg, gif, webp) and "
172
+ "PDFs can be read; inspect other formats with a command"
173
+ )
174
+ text = data.decode("utf-8", errors="replace")
175
+ track_read(ctx, path)
176
+ lines = text.splitlines()
177
+ if not lines:
178
+ return "(empty file)"
179
+ offset = max(1, int(args.get("offset") or 1))
180
+ if offset > len(lines):
181
+ raise ToolError(f"offset {offset} is past the end of the file ({len(lines)} lines)")
182
+ limit = min(max(int(args.get("limit") or MAX_READ_LINES), 1), MAX_READ_LINES)
183
+ width = len(str(min(offset + limit, len(lines))))
184
+ out: list[str] = []
185
+ used = 0
186
+ clipped = 0
187
+ for number in range(offset, min(offset + limit, len(lines) + 1)):
188
+ line = lines[number - 1]
189
+ if len(line) > MAX_LINE:
190
+ line = line[:MAX_LINE] + f" ... [line clipped, {len(line)} chars]"
191
+ clipped += 1
192
+ entry = f"{str(number).rjust(width)}\t{line}"
193
+ if out and used + len(entry) > MAX_READ_CHARS:
194
+ break
195
+ out.append(entry)
196
+ used += len(entry) + 1
197
+ last = offset + len(out) - 1
198
+ header = f"[{ctx.display(path)}: lines {offset}-{last} of {len(lines)}]"
199
+ body = header + "\n" + "\n".join(out)
200
+ if last < len(lines):
201
+ body += f"\n... ({len(lines) - last} more lines; continue with offset={last + 1})"
202
+ return body
203
+
204
+
205
+ def track_read(ctx: ToolContext, path: Path) -> None:
206
+ ctx.state.setdefault("files_read", set()).add(str(path))
207
+
208
+
209
+ # ----------------------------------------------------------------------------- write
210
+
211
+ MAX_DIFF_LINES = 60
212
+
213
+
214
+ def diff_preview(before: str, after: str, name: str) -> str:
215
+ """A short unified diff of a change, for the tool result and approval prompts."""
216
+ lines = list(
217
+ difflib.unified_diff(
218
+ before.splitlines(), after.splitlines(), f"a/{name}", f"b/{name}", n=2, lineterm=""
219
+ )
220
+ )
221
+ if not lines:
222
+ return "(no textual change)"
223
+ added = sum(1 for x in lines if x.startswith("+") and not x.startswith("+++"))
224
+ removed = sum(1 for x in lines if x.startswith("-") and not x.startswith("---"))
225
+ shown = [x[:300] for x in lines[:MAX_DIFF_LINES]]
226
+ if len(lines) > MAX_DIFF_LINES:
227
+ shown.append(f"... ({len(lines) - MAX_DIFF_LINES} more diff lines)")
228
+ return f"+{added} -{removed}\n" + "\n".join(shown)
229
+
230
+
231
+ def write_file(args: dict[str, Any], ctx: ToolContext) -> str:
232
+ path = ctx.resolve(args["path"], for_write=True)
233
+ if path.is_dir():
234
+ raise ToolError(f"{args['path']} is a directory")
235
+ path.parent.mkdir(parents=True, exist_ok=True)
236
+ existed = path.exists()
237
+ before = path.read_text(encoding="utf-8", errors="replace") if existed else ""
238
+ path.write_text(args["content"], encoding="utf-8")
239
+ track_read(ctx, path)
240
+ if not existed:
241
+ lines = args["content"].count("\n") + (0 if args["content"].endswith("\n") else 1)
242
+ return f"created {ctx.display(path)} ({len(args['content'])} chars, {lines} lines)"
243
+ return f"updated {ctx.display(path)} ({len(args['content'])} chars)\n" + diff_preview(
244
+ before, args["content"], ctx.display(path)
245
+ )
246
+
247
+
248
+ def _apply_edit(
249
+ text: str, old: str, new: str, replace_all: bool, label: str = ""
250
+ ) -> tuple[str, int]:
251
+ if old == new:
252
+ raise ToolError(f"{label}old_string and new_string are identical")
253
+ if old == "":
254
+ raise ToolError(f"{label}old_string is empty; use write_file to create a file")
255
+ count = text.count(old)
256
+ if count == 0:
257
+ raise ToolError(
258
+ f"{label}old_string not found; read the file again and copy the text exactly"
259
+ )
260
+ if count > 1 and not replace_all:
261
+ raise ToolError(
262
+ f"{label}old_string occurs {count} times; add context to make it unique or set "
263
+ "replace_all"
264
+ )
265
+ if replace_all:
266
+ return text.replace(old, new), count
267
+ return text.replace(old, new, 1), 1
268
+
269
+
270
+ def edit_file(args: dict[str, Any], ctx: ToolContext) -> str:
271
+ path = ctx.resolve(args["path"], must_exist=True, for_write=True)
272
+ text = path.read_text(encoding="utf-8")
273
+ updated, count = _apply_edit(
274
+ text, args["old_string"], args["new_string"], bool(args.get("replace_all"))
275
+ )
276
+ path.write_text(updated, encoding="utf-8")
277
+ track_read(ctx, path)
278
+ return f"edited {ctx.display(path)} ({count} replacement(s))\n" + diff_preview(
279
+ text, updated, ctx.display(path)
280
+ )
281
+
282
+
283
+ def multi_edit(args: dict[str, Any], ctx: ToolContext) -> str:
284
+ path = ctx.resolve(args["path"], must_exist=True, for_write=True)
285
+ edits = args["edits"]
286
+ if not edits:
287
+ raise ToolError("edits is empty")
288
+ text = path.read_text(encoding="utf-8")
289
+ updated, total = text, 0
290
+ for index, edit in enumerate(edits, start=1):
291
+ if not isinstance(edit, dict) or "old_string" not in edit or "new_string" not in edit:
292
+ raise ToolError(f"edit {index}: needs old_string and new_string")
293
+ updated, count = _apply_edit(
294
+ updated,
295
+ str(edit["old_string"]),
296
+ str(edit["new_string"]),
297
+ bool(edit.get("replace_all")),
298
+ f"edit {index}: ",
299
+ )
300
+ total += count
301
+ # All edits applied in memory; only now does the file change (all or nothing).
302
+ path.write_text(updated, encoding="utf-8")
303
+ track_read(ctx, path)
304
+ return (
305
+ f"edited {ctx.display(path)} ({len(edits)} edits, {total} replacement(s))\n"
306
+ + diff_preview(text, updated, ctx.display(path))
307
+ )
308
+
309
+
310
+ def delete_file(args: dict[str, Any], ctx: ToolContext) -> str:
311
+ path = ctx.resolve(args["path"], must_exist=True, for_write=True)
312
+ if path == ctx.workspace or (ctx.scratch is not None and path == ctx.scratch.resolve()):
313
+ raise ToolError("refusing to delete the workspace or scratch root")
314
+ if path.is_dir():
315
+ if not args.get("recursive"):
316
+ raise ToolError(f"{args['path']} is a directory; set recursive=true to delete it")
317
+ count = sum(1 for _ in path.rglob("*"))
318
+ shutil.rmtree(path)
319
+ return f"deleted directory {ctx.display(path)} ({count} entries)"
320
+ size = path.stat().st_size
321
+ path.unlink()
322
+ return f"deleted {ctx.display(path)} ({size} bytes)"
323
+
324
+
325
+ def write_binary_file(args: dict[str, Any], ctx: ToolContext) -> str:
326
+ path = ctx.resolve(args["path"], for_write=True)
327
+ try:
328
+ data = base64.b64decode("".join(str(args["data"]).split()), validate=True)
329
+ except ValueError as exc:
330
+ raise ToolError(f"data is not valid base64: {exc}") from exc
331
+ if len(data) > MAX_FILE_BYTES:
332
+ raise ToolError("file is too large")
333
+ path.parent.mkdir(parents=True, exist_ok=True)
334
+ existed = path.exists()
335
+ path.write_bytes(data)
336
+ return f"{'updated' if existed else 'created'} {ctx.display(path)} ({len(data)} bytes)"
337
+
338
+
339
+ # ---------------------------------------------------------------------------- search
340
+
341
+ MAX_GLOB = 500
342
+ SEARCH_SECONDS = 20.0
343
+
344
+
345
+ def glob_regex(pattern: str) -> re.Pattern[str]:
346
+ """Translate a glob with ``**``, ``*``, ``?``, ``[..]`` and ``{a,b}`` to a regex."""
347
+ out, i = [], 0
348
+ while i < len(pattern):
349
+ char = pattern[i]
350
+ if pattern.startswith("**/", i):
351
+ out.append("(?:.*/)?")
352
+ i += 3
353
+ continue
354
+ if pattern.startswith("**", i):
355
+ out.append(".*")
356
+ i += 2
357
+ continue
358
+ if char == "*":
359
+ out.append("[^/]*")
360
+ elif char == "?":
361
+ out.append("[^/]")
362
+ elif char == "[":
363
+ close = pattern.find("]", i + 1)
364
+ if close == -1:
365
+ out.append(re.escape(char))
366
+ else:
367
+ body = pattern[i + 1 : close].replace("\\", "\\\\")
368
+ if body.startswith("!"):
369
+ body = "^" + body[1:]
370
+ out.append(f"[{body}]")
371
+ i = close
372
+ elif char == "{":
373
+ close = pattern.find("}", i + 1)
374
+ if close == -1:
375
+ out.append(re.escape(char))
376
+ else:
377
+ options = pattern[i + 1 : close].split(",")
378
+ out.append("(?:" + "|".join(glob_regex(o).pattern[:-2] for o in options) + ")")
379
+ i = close
380
+ else:
381
+ out.append(re.escape(char))
382
+ i += 1
383
+ return re.compile("".join(out) + r"\Z")
384
+
385
+
386
+ def _matcher(pattern: str):
387
+ pattern = pattern.strip()
388
+ if pattern.startswith("./"):
389
+ pattern = pattern[2:]
390
+ anchored = "/" in pattern
391
+ regex = glob_regex(pattern.lstrip("/"))
392
+
393
+ def match(relative: str) -> bool:
394
+ if anchored:
395
+ return bool(regex.match(relative))
396
+ return bool(regex.match(relative.rsplit("/", 1)[-1]))
397
+
398
+ return match
399
+
400
+
401
+ def glob_files(args: dict[str, Any], ctx: ToolContext) -> str:
402
+ root = ctx.resolve(args.get("path"), must_exist=True)
403
+ match = _matcher(args["pattern"])
404
+ deadline = time.monotonic() + SEARCH_SECONDS
405
+ matches: list[str] = []
406
+ note = ""
407
+ for file in _iter_files(root):
408
+ if match(file.relative_to(root).as_posix()):
409
+ matches.append(ctx.display(file))
410
+ if len(matches) >= MAX_GLOB:
411
+ note = f"... (stopped at {MAX_GLOB} matches; use a narrower pattern or path)"
412
+ break
413
+ if time.monotonic() > deadline:
414
+ note = "... (search stopped at its time limit; the list is incomplete)"
415
+ break
416
+ if note:
417
+ matches.append(note)
418
+ return "\n".join(matches) or "no matches"
419
+
420
+
421
+ def grep(args: dict[str, Any], ctx: ToolContext) -> str:
422
+ root = ctx.resolve(args.get("path"), must_exist=True)
423
+ pattern = args["pattern"]
424
+ if args.get("literal"):
425
+ pattern = re.escape(pattern)
426
+ try:
427
+ regex = re.compile(pattern, 0 if args.get("case_sensitive", True) else re.IGNORECASE)
428
+ except re.error as exc:
429
+ raise ToolError(
430
+ f"invalid regex: {exc}. Escape special characters such as ( ) [ ] . or set "
431
+ "literal=true to search for the text as written"
432
+ ) from exc
433
+ mode = args.get("output_mode") or "content"
434
+ context = min(max(int(args.get("context") or 0), 0), 10)
435
+ max_count = min(max(int(args.get("max_count") or 300), 1), 2000)
436
+ include = _matcher(args["glob"]) if args.get("glob") else None
437
+ files = [root] if root.is_file() else list(_iter_files(root))
438
+ deadline = time.monotonic() + SEARCH_SECONDS
439
+ results: list[str] = []
440
+ found = 0
441
+ note = ""
442
+ for file in files:
443
+ if include and file != root and not include(file.relative_to(root).as_posix()):
444
+ continue
445
+ if time.monotonic() > deadline:
446
+ note = "... (search stopped at its time limit; narrow the path or glob)"
447
+ break
448
+ try:
449
+ if file.stat().st_size > MAX_FILE_BYTES:
450
+ continue
451
+ with file.open(encoding="utf-8", errors="strict") as handle:
452
+ lines = handle.read().splitlines()
453
+ except (UnicodeDecodeError, OSError):
454
+ continue
455
+ hits = [n for n, line in enumerate(lines, start=1) if regex.search(line)]
456
+ if not hits:
457
+ continue
458
+ name = ctx.display(file)
459
+ if mode == "files_with_matches":
460
+ results.append(name)
461
+ found += 1
462
+ elif mode == "count":
463
+ results.append(f"{name}: {len(hits)}")
464
+ found += 1
465
+ else:
466
+ shown: set[int] = set()
467
+ for number in hits:
468
+ if found >= max_count:
469
+ break
470
+ found += 1
471
+ low, high = max(1, number - context), min(len(lines), number + context)
472
+ if context and shown and low > max(shown) + 1:
473
+ results.append("--")
474
+ for n in range(low, high + 1):
475
+ if n in shown:
476
+ continue
477
+ shown.add(n)
478
+ sep = ":" if n == number or n in hits else "-"
479
+ results.append(f"{name}:{n}{sep} {lines[n - 1].rstrip()[:300]}")
480
+ if found >= max_count:
481
+ note = f"... (stopped at {max_count} matches; narrow the pattern or path, or raise max_count)"
482
+ break
483
+ if note:
484
+ results.append(note)
485
+ return "\n".join(results) or "no matches"
486
+
487
+
488
+ _DANGEROUS = re.compile(
489
+ r"(^|[;&|\s])(rm\s+-[a-z]*r|sudo\b|mkfs|dd\s+if=|shutdown|reboot|chmod\s+-R\s+777|curl\b[^|]*\|\s*(sh|bash)|"
490
+ r"wget\b[^|]*\|\s*(sh|bash)|git\s+push|git\s+reset\s+--hard|:\(\)\s*\{)",
491
+ )
492
+ _READ_ONLY_COMMANDS = {
493
+ "ls",
494
+ "cat",
495
+ "head",
496
+ "tail",
497
+ "wc",
498
+ "grep",
499
+ "rg",
500
+ "find",
501
+ "pwd",
502
+ "echo",
503
+ "which",
504
+ "file",
505
+ "stat",
506
+ "du",
507
+ "kicad-cli",
508
+ "git",
509
+ "python",
510
+ "python3",
511
+ "diff",
512
+ "sort",
513
+ "uniq",
514
+ }
515
+
516
+
517
+ def dangerous_command(command: str) -> bool:
518
+ return bool(_DANGEROUS.search(command))
519
+
520
+
521
+ def simple_command(command: str) -> bool:
522
+ """True for a single command without chaining or redirection whose program is well known."""
523
+ if re.search(r"(\n|&&|\|\||[;&|`<>]|\$\()", command):
524
+ return False
525
+ words = command.strip().split()
526
+ return bool(words) and Path(words[0]).name in _READ_ONLY_COMMANDS
527
+
528
+
529
+ def _shell_env() -> dict[str, str]:
530
+ return {**os.environ, "QT_QPA_PLATFORM": "offscreen"}
531
+
532
+
533
+ async def execute(args: dict[str, Any], ctx: ToolContext) -> str | dict[str, Any]:
534
+ if ctx.read_only:
535
+ raise ToolError("shell commands are disabled in read-only sessions")
536
+ command = args["command"]
537
+ cwd = ctx.resolve(args.get("cwd"), must_exist=True)
538
+ if args.get("background"):
539
+ return await start_background(
540
+ command, cwd, _shell_env(), ctx, str(args.get("description") or "")
541
+ )
542
+ timeout = min(float(args.get("timeout") or ctx.shell_timeout), 1800.0)
543
+ process = await asyncio.create_subprocess_shell(
544
+ command,
545
+ cwd=cwd,
546
+ stdin=asyncio.subprocess.DEVNULL,
547
+ stdout=asyncio.subprocess.PIPE,
548
+ stderr=asyncio.subprocess.STDOUT,
549
+ env=_shell_env(),
550
+ start_new_session=os.name != "nt",
551
+ )
552
+ try:
553
+ output, _ = await asyncio.wait_for(process.communicate(), timeout=timeout)
554
+ except (TimeoutError, asyncio.CancelledError) as exc:
555
+ try:
556
+ os.killpg(os.getpgid(process.pid), 9)
557
+ except (ProcessLookupError, PermissionError, OSError, AttributeError):
558
+ process.kill()
559
+ await process.wait()
560
+ if isinstance(exc, asyncio.CancelledError):
561
+ raise
562
+ raise ToolError(
563
+ f"command timed out after {timeout:.0f}s and was stopped. For long jobs use "
564
+ "background=true and process_output"
565
+ ) from None
566
+ text = output.decode("utf-8", errors="replace").replace("\r\n", "\n").rstrip()
567
+ # Long output is saved to a file by the executor, with a head/tail preview.
568
+ return f"{text or '(no output)'}\n[exit code {process.returncode}]"
569
+
570
+
571
+ async def ask_user(args: dict[str, Any], ctx: ToolContext) -> str:
572
+ if ctx.ask_user is None:
573
+ return "No user is available to answer. State your assumption and continue."
574
+ options = [str(o) for o in args.get("options") or []]
575
+ answer = await ctx.ask_user(args["question"], options)
576
+ return answer or "(no answer)"
577
+
578
+
579
+ _PATH = {"type": "string", "description": "Path relative to the workspace root"}
580
+
581
+ FILE_TOOLS = [
582
+ Tool(
583
+ "ls",
584
+ "List a directory in the workspace.",
585
+ {"type": "object", "properties": {"path": _PATH}},
586
+ ls,
587
+ ),
588
+ Tool(
589
+ "read_file",
590
+ "Read a file with line numbers. KiCad files are text (S-expressions): find the part you "
591
+ "need with grep first, then read that range with offset/limit; one call returns at most "
592
+ f"{MAX_READ_LINES} lines or about {MAX_READ_CHARS // 1000}k characters and says where to "
593
+ "continue. Images (png, jpg, gif, webp) are shown to you as images, so you can look at "
594
+ "board renders and screenshots; set as_image=true to see an SVG (e.g. a KiCad export) "
595
+ "rendered. PDFs (datasheets) are converted to text, up to "
596
+ f"{MAX_PDF_PAGES} pages per call (pages='1-5'). Oversized tool results that were saved "
597
+ "to .traceforge/scratch/tool-output/ can be paged through here too.",
598
+ {
599
+ "type": "object",
600
+ "properties": {
601
+ "path": _PATH,
602
+ "offset": {"type": "integer", "description": "1-based first line"},
603
+ "limit": {"type": "integer", "description": f"max lines (<= {MAX_READ_LINES})"},
604
+ "pages": {"type": "string", "description": "PDF page range, e.g. '3' or '1-5'"},
605
+ "as_image": {"type": "boolean", "description": "render an SVG to an image"},
606
+ },
607
+ "required": ["path"],
608
+ },
609
+ read_file,
610
+ spill=False,
611
+ ),
612
+ Tool(
613
+ "write_file",
614
+ "Create or overwrite a text file in the workspace. Returns a diff when it replaces an "
615
+ "existing file. Prefer edit_file or multi_edit for changes to existing files.",
616
+ {
617
+ "type": "object",
618
+ "properties": {"path": _PATH, "content": {"type": "string"}},
619
+ "required": ["path", "content"],
620
+ },
621
+ write_file,
622
+ ToolKind.WRITE,
623
+ "path",
624
+ ),
625
+ Tool(
626
+ "edit_file",
627
+ "Replace exact text in a file. old_string must match exactly once unless replace_all is "
628
+ "true. Returns a diff of the change. Prefer the structured kicad_* tools for placements, "
629
+ "tracks and fields.",
630
+ {
631
+ "type": "object",
632
+ "properties": {
633
+ "path": _PATH,
634
+ "old_string": {"type": "string"},
635
+ "new_string": {"type": "string"},
636
+ "replace_all": {"type": "boolean"},
637
+ },
638
+ "required": ["path", "old_string", "new_string"],
639
+ },
640
+ edit_file,
641
+ ToolKind.WRITE,
642
+ "path",
643
+ ),
644
+ Tool(
645
+ "multi_edit",
646
+ "Apply several exact-text replacements to one file in order, all or nothing: if any "
647
+ "edit does not match, the file is left unchanged. Each edit sees the result of the "
648
+ "previous one. Use it instead of many edit_file calls on the same file.",
649
+ {
650
+ "type": "object",
651
+ "properties": {
652
+ "path": _PATH,
653
+ "edits": {
654
+ "type": "array",
655
+ "items": {
656
+ "type": "object",
657
+ "properties": {
658
+ "old_string": {"type": "string"},
659
+ "new_string": {"type": "string"},
660
+ "replace_all": {"type": "boolean"},
661
+ },
662
+ "required": ["old_string", "new_string"],
663
+ },
664
+ },
665
+ },
666
+ "required": ["path", "edits"],
667
+ },
668
+ multi_edit,
669
+ ToolKind.WRITE,
670
+ "path",
671
+ ),
672
+ Tool(
673
+ "delete_file",
674
+ "Delete a file (or a directory with recursive=true) in the workspace. This cannot be "
675
+ "undone, so it asks the user unless approvals are set to yolo.",
676
+ {
677
+ "type": "object",
678
+ "properties": {"path": _PATH, "recursive": {"type": "boolean"}},
679
+ "required": ["path"],
680
+ },
681
+ delete_file,
682
+ ToolKind.WRITE,
683
+ "path",
684
+ ),
685
+ Tool(
686
+ "write_binary_file",
687
+ "Write a binary file (base64 data), e.g. an image or an archive you generated.",
688
+ {
689
+ "type": "object",
690
+ "properties": {
691
+ "path": _PATH,
692
+ "data": {"type": "string", "description": "base64-encoded content"},
693
+ },
694
+ "required": ["path", "data"],
695
+ },
696
+ write_binary_file,
697
+ ToolKind.WRITE,
698
+ "path",
699
+ ),
700
+ Tool(
701
+ "glob",
702
+ "Find files by glob pattern. '*' stays within a folder, '**' crosses folders, {a,b} "
703
+ "gives alternatives: '*.kicad_sch' (any depth), 'hardware/**/*.kicad_{sch,pcb}'. A "
704
+ f"pattern without '/' matches file names at any depth. At most {MAX_GLOB} results.",
705
+ {
706
+ "type": "object",
707
+ "properties": {"pattern": {"type": "string"}, "path": _PATH},
708
+ "required": ["pattern"],
709
+ },
710
+ glob_files,
711
+ ),
712
+ Tool(
713
+ "grep",
714
+ "Search file contents with a regular expression (set literal=true to search for text "
715
+ "as written, e.g. '(property \"Reference\"'). output_mode: 'content' (default, "
716
+ "file:line: text, with optional context lines), 'files_with_matches' or 'count'. "
717
+ "Stops at max_count matches (default 300) and says so.",
718
+ {
719
+ "type": "object",
720
+ "properties": {
721
+ "pattern": {"type": "string"},
722
+ "path": _PATH,
723
+ "glob": {
724
+ "type": "string",
725
+ "description": "only files matching this glob, e.g. *.kicad_pcb",
726
+ },
727
+ "case_sensitive": {"type": "boolean"},
728
+ "literal": {"type": "boolean", "description": "match the pattern as plain text"},
729
+ "output_mode": {
730
+ "type": "string",
731
+ "enum": ["content", "files_with_matches", "count"],
732
+ },
733
+ "context": {"type": "integer", "description": "lines before/after (<= 10)"},
734
+ "max_count": {"type": "integer", "description": "stop after this many matches"},
735
+ },
736
+ "required": ["pattern"],
737
+ },
738
+ grep,
739
+ ),
740
+ Tool(
741
+ "execute",
742
+ "Run a shell command in the workspace and return its output and exit code (default "
743
+ "timeout 120 s, max 1800; a command that times out is stopped). Use it for kicad-cli, "
744
+ "scripts and simulations. For long jobs set background=true: the command keeps running, "
745
+ "you get an id at once, and process_output reports its progress. Very long output is "
746
+ "saved to a file and you get the head and tail.",
747
+ {
748
+ "type": "object",
749
+ "properties": {
750
+ "command": {"type": "string"},
751
+ "cwd": _PATH,
752
+ "timeout": {"type": "number", "description": "seconds, max 1800"},
753
+ "background": {"type": "boolean", "description": "run detached; returns an id"},
754
+ "description": {"type": "string", "description": "what the command is for"},
755
+ },
756
+ "required": ["command"],
757
+ },
758
+ execute,
759
+ ToolKind.SHELL,
760
+ "command",
761
+ ),
762
+ Tool(
763
+ "ask_user",
764
+ "Ask the user a question when a decision is genuinely theirs (requirements, trade-offs, budget). "
765
+ "Offer options when there are clear choices.",
766
+ {
767
+ "type": "object",
768
+ "properties": {
769
+ "question": {"type": "string"},
770
+ "options": {"type": "array", "items": {"type": "string"}},
771
+ },
772
+ "required": ["question"],
773
+ },
774
+ ask_user,
775
+ ToolKind.INTERACTIVE,
776
+ ),
777
+ ]