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,141 @@
1
+ """Agent tools for 3D models: check, assign and render (see traceforge.kicad.models3d)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from traceforge.kicad import models3d
9
+ from traceforge.tools.base import Tool, ToolContext, ToolError, ToolKind, ToolOutput
10
+ from traceforge.tools.files import _image_output
11
+ from traceforge.tools.kicad_tools import _out_dir, _pcb_path
12
+
13
+ _PCB = {"type": "string", "description": "board path (default: the project's)"}
14
+ _VEC = {"type": "array", "items": {"type": "number"}, "minItems": 3, "maxItems": 3}
15
+
16
+
17
+ def _model_file(raw: str, ctx: ToolContext) -> Path:
18
+ """A model inside the workspace, or one inside an installed KiCad 3D library."""
19
+ candidate = Path(raw).expanduser()
20
+ if candidate.is_absolute() and candidate.is_file():
21
+ resolved = candidate.resolve()
22
+ for base in models3d.model_dirs():
23
+ if base.resolve() in resolved.parents:
24
+ return resolved
25
+ return ctx.resolve(raw, must_exist=True)
26
+
27
+
28
+ def models_check(args: dict[str, Any], ctx: ToolContext) -> dict:
29
+ pcb = _pcb_path(args, ctx)
30
+ rows = models3d.inventory(pcb)
31
+ problems = [r.to_dict() for r in rows if r.status != "resolved"]
32
+ findings = models3d.check_models(pcb)
33
+ return {
34
+ "summary": models3d.summary(rows),
35
+ "model_libraries": [str(d) for d in models3d.model_dirs()],
36
+ "problems": problems,
37
+ "findings": [f.to_dict() for f in findings],
38
+ "note": "proposals carry a confidence; only 0.90 and above are exact name matches",
39
+ }
40
+
41
+
42
+ def models_assign(args: dict[str, Any], ctx: ToolContext) -> dict:
43
+ pcb = _pcb_path(args, ctx, for_write=True)
44
+ overwrite = bool(args.get("overwrite"))
45
+ try:
46
+ if args.get("auto"):
47
+ return {
48
+ "results": models3d.assign_proposed(pcb, float(args.get("min_confidence") or 0.9))
49
+ }
50
+ ref = args.get("ref")
51
+ if not ref:
52
+ raise ToolError("pass 'ref' (with 'model' or 'generate') or auto=true")
53
+ if args.get("generate"):
54
+ return models3d.assign_generic(pcb, ref, args["generate"], overwrite=overwrite)
55
+ if not args.get("model"):
56
+ raise ToolError("pass 'model' (a .step/.wrl file), 'generate', or auto=true")
57
+ vec = lambda key, default: tuple(float(v) for v in args.get(key) or default) # noqa: E731
58
+ return models3d.assign_model(
59
+ pcb,
60
+ ref,
61
+ _model_file(args["model"], ctx),
62
+ vec("offset", (0, 0, 0)),
63
+ vec("scale", (1, 1, 1)),
64
+ vec("rotate", (0, 0, 0)),
65
+ overwrite=overwrite,
66
+ )
67
+ except models3d.ModelError as exc:
68
+ raise ToolError(str(exc)) from exc
69
+
70
+
71
+ def render_3d(args: dict[str, Any], ctx: ToolContext) -> ToolOutput:
72
+ pcb = _pcb_path(args, ctx)
73
+ side = args.get("side") or "top"
74
+ out = _out_dir(ctx, "render") / f"{pcb.stem}-{side}.png"
75
+ try:
76
+ models3d.render_3d(
77
+ pcb,
78
+ out,
79
+ side,
80
+ width=int(args.get("width") or 1600),
81
+ height=int(args.get("height") or 900),
82
+ quality=args.get("quality") or "basic",
83
+ perspective=bool(args.get("perspective")),
84
+ )
85
+ except models3d.RenderError as exc:
86
+ raise ToolError(str(exc)) from exc
87
+ return _image_output(out, ctx)
88
+
89
+
90
+ MODELS3D_TOOLS = [
91
+ Tool(
92
+ "kicad_models_check",
93
+ "Check every footprint's 3D model: resolved path, missing file, or no model. Lists "
94
+ "library proposals with a confidence for footprints that need one. Read-only.",
95
+ {"type": "object", "properties": {"pcb": _PCB}},
96
+ models_check,
97
+ ),
98
+ Tool(
99
+ "kicad_models_assign",
100
+ "Write a (model ...) entry into a board footprint. Give ref + model (a .step/.wrl in the "
101
+ "workspace or KiCad's 3D library; files outside the project are copied to 3dmodels/), or "
102
+ "ref + generate ('step'|'wrl') for a labelled generic placeholder sized from the "
103
+ "footprint, or auto=true to assign exact library matches (min_confidence, default 0.9). "
104
+ "Never replaces an existing model unless overwrite=true.",
105
+ {
106
+ "type": "object",
107
+ "properties": {
108
+ "pcb": _PCB,
109
+ "ref": {"type": "string"},
110
+ "model": {"type": "string"},
111
+ "generate": {"type": "string", "enum": ["step", "wrl"]},
112
+ "auto": {"type": "boolean"},
113
+ "min_confidence": {"type": "number"},
114
+ "offset": _VEC,
115
+ "scale": _VEC,
116
+ "rotate": _VEC,
117
+ "overwrite": {"type": "boolean"},
118
+ },
119
+ },
120
+ models_assign,
121
+ ToolKind.WRITE,
122
+ "pcb",
123
+ ),
124
+ Tool(
125
+ "kicad_render_3d",
126
+ "Render the board in 3D (top or bottom) with `kicad-cli pcb render` and show the image. "
127
+ "Needs KiCad 8 or later; on KiCad 7 it reports that plainly.",
128
+ {
129
+ "type": "object",
130
+ "properties": {
131
+ "pcb": _PCB,
132
+ "side": {"type": "string", "enum": ["top", "bottom"]},
133
+ "width": {"type": "integer"},
134
+ "height": {"type": "integer"},
135
+ "quality": {"type": "string", "enum": ["basic", "high"]},
136
+ "perspective": {"type": "boolean"},
137
+ },
138
+ },
139
+ render_3d,
140
+ ),
141
+ ]
@@ -0,0 +1,473 @@
1
+ """Agent tools for live part data, datasheets and the web. All read-only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from traceforge.parts import Offline, ProviderError, default_catalog, is_lcsc_code
10
+ from traceforge.tools.base import Tool, ToolContext, ToolError, ToolKind
11
+
12
+
13
+ def _offline_error(exc: Exception) -> ToolError:
14
+ return ToolError(
15
+ f"not verified: offline or unreachable ({exc}). Do not guess part numbers; say they are "
16
+ "unverified."
17
+ )
18
+
19
+
20
+ def part_lookup(args: dict[str, Any], ctx: ToolContext) -> dict:
21
+ catalog = default_catalog()
22
+ queries = [q for q in [args.get("part"), *(args.get("parts") or [])] if q and str(q).strip()]
23
+ if not queries:
24
+ raise ToolError("give 'part' (an LCSC code such as C2286 or an MPN) or 'parts'")
25
+ reports = catalog.lookup_many([str(q) for q in queries[:40]])
26
+ out: dict[str, Any] = {}
27
+ for query, report in reports.items():
28
+ if report.part is not None:
29
+ out[query] = report.part.to_dict(brief=not args.get("full"))
30
+ elif report.answered:
31
+ out[query] = {
32
+ "found": False,
33
+ "note": "no catalogue knows this part number"
34
+ + ("; it is not a valid LCSC code" if not is_lcsc_code(query) else ""),
35
+ "asked": report.describe(),
36
+ }
37
+ else:
38
+ out[query] = {
39
+ "found": False,
40
+ "note": "not verified: offline",
41
+ "asked": report.describe(),
42
+ }
43
+ return out
44
+
45
+
46
+ def part_search(args: dict[str, Any], ctx: ToolContext) -> dict:
47
+ try:
48
+ results = default_catalog().search(
49
+ args["query"],
50
+ package=args.get("package"),
51
+ basic_only=bool(args.get("basic_only", False)),
52
+ in_stock=bool(args.get("in_stock", True)),
53
+ limit=int(args.get("limit") or 10),
54
+ )
55
+ except Offline as exc:
56
+ raise _offline_error(exc) from exc
57
+ return {
58
+ "query": args["query"],
59
+ "results": [p.to_dict(brief=True) for p in results],
60
+ "note": "sorted basic first, then by stock. Verify with part_lookup before writing an "
61
+ "LCSC/MPN field, and check the package against the footprint."
62
+ if results
63
+ else "no matches: use fewer words (MPN prefix, value + package) or drop filters",
64
+ }
65
+
66
+
67
+ def parts_check(args: dict[str, Any], ctx: ToolContext) -> dict:
68
+ from traceforge.checks import summarize
69
+ from traceforge.kicad.pcb import read_board
70
+ from traceforge.kicad.project import discover
71
+ from traceforge.kicad.schematic import read_schematic
72
+ from traceforge.parts.verify import check_parts
73
+
74
+ ws = discover(ctx.workspace)
75
+ schematic = (
76
+ ctx.resolve(args["schematic"], must_exist=True) if args.get("schematic") else ws.schematic
77
+ )
78
+ if schematic is None:
79
+ raise ToolError("no schematic found")
80
+ pcb = ctx.resolve(args["pcb"], must_exist=True) if args.get("pcb") else ws.pcb
81
+ board = read_board(pcb) if pcb else None
82
+ findings, summary = check_parts(read_schematic(schematic), board)
83
+ return {"summary": summary, **summarize(findings)}
84
+
85
+
86
+ def parts_verify(args: dict[str, Any], ctx: ToolContext) -> dict:
87
+ from traceforge.parts.report import brief, verify_design
88
+
89
+ folder = ctx.resolve(args["path"], must_exist=True) if args.get("path") else ctx.workspace
90
+ data = verify_design(folder)
91
+ ctx.state["parts_verified"] = data["all_verified"]
92
+ out = brief(data)
93
+ if out.get("report"):
94
+ out["report"] = ctx.display(Path(out["report"]))
95
+ return out
96
+
97
+
98
+ def datasheet_fetch(args: dict[str, Any], ctx: ToolContext) -> dict:
99
+ from traceforge.parts.datasheet import fetch_datasheet, select
100
+
101
+ folder = ctx.workspace / ".traceforge" / "datasheets"
102
+ url = args.get("url")
103
+ name = None
104
+ local: Path | None = None
105
+ part_info = None
106
+ if args.get("path"):
107
+ local = ctx.resolve(args["path"], must_exist=True)
108
+ elif not url:
109
+ part = args.get("part")
110
+ if not part:
111
+ raise ToolError("give 'url', 'part' (LCSC code or MPN) or 'path'")
112
+ try:
113
+ info = default_catalog().lookup(part)
114
+ except Offline as exc:
115
+ raise _offline_error(exc) from exc
116
+ if info is None or not info.datasheet_url:
117
+ raise ToolError(f"no datasheet URL known for {part}; try web_search")
118
+ url, name = info.datasheet_url, f"{info.lcsc or ''}_{info.mpn}"
119
+ part_info = {"lcsc": info.lcsc, "mpn": info.mpn, "manufacturer": info.manufacturer}
120
+ try:
121
+ path, pages, outline = fetch_datasheet(folder, url=url, name=name, local=local)
122
+ except Offline as exc:
123
+ raise _offline_error(exc) from exc
124
+ except ProviderError as exc:
125
+ raise ToolError(str(exc)) from exc
126
+ result = select(
127
+ pages,
128
+ outline,
129
+ page_spec=args.get("pages"),
130
+ query=args.get("query"),
131
+ max_chars=int(args.get("max_chars") or 24_000),
132
+ )
133
+ result = {"saved": ctx.display(path), "source": url or str(local), **result}
134
+ if part_info:
135
+ result["part"] = part_info
136
+ return result
137
+
138
+
139
+ def web_search_tool(args: dict[str, Any], ctx: ToolContext) -> dict:
140
+ from traceforge.parts.web import web_search
141
+
142
+ try:
143
+ answer = web_search(args["query"], limit=int(args.get("limit") or 8))
144
+ except Offline as exc:
145
+ raise _offline_error(exc) from exc
146
+ except ProviderError as exc:
147
+ raise ToolError(str(exc)) from exc
148
+ return answer.to_dict()
149
+
150
+
151
+ def web_fetch_tool(args: dict[str, Any], ctx: ToolContext) -> dict:
152
+ from traceforge.parts.web import web_fetch
153
+
154
+ try:
155
+ return web_fetch(
156
+ args["url"],
157
+ max_chars=min(int(args.get("max_chars") or 20_000), 60_000),
158
+ offset=int(args.get("offset") or 0),
159
+ find=args.get("find"),
160
+ )
161
+ except Offline as exc:
162
+ raise _offline_error(exc) from exc
163
+ except ProviderError as exc:
164
+ raise ToolError(str(exc)) from exc
165
+
166
+
167
+ def reference_projects(args: dict[str, Any], ctx: ToolContext) -> dict:
168
+ from traceforge.parts.web import reference_project_search
169
+
170
+ return reference_project_search(
171
+ args["query"], tool=args.get("tool") or "kicad", limit=int(args.get("limit") or 8)
172
+ )
173
+
174
+
175
+ def _qty_list(args: dict[str, Any], key: str, default: tuple[int, ...]) -> tuple[int, ...]:
176
+ raw = args.get(key)
177
+ if not raw:
178
+ return default
179
+ try:
180
+ values = sorted({int(x) for x in raw})
181
+ except (TypeError, ValueError) as exc:
182
+ raise ToolError(f"{key} must be a list of whole numbers") from exc
183
+ if not values or values[0] < 1:
184
+ raise ToolError(f"{key} must be positive")
185
+ return tuple(values)
186
+
187
+
188
+ def sourcing_compare(args: dict[str, Any], ctx: ToolContext) -> dict:
189
+ """Merged supplier comparison for one part."""
190
+ from traceforge.parts.supply import source_part
191
+
192
+ if not args.get("part"):
193
+ raise ToolError("give 'part' (LCSC code or MPN)")
194
+ result = source_part(
195
+ str(args["part"]),
196
+ qtys=_qty_list(args, "quantities", (1, 10, 100, 1000)),
197
+ currency=str(args.get("currency") or "USD"),
198
+ destination=str(args.get("country") or ""),
199
+ )
200
+ return result.to_dict()
201
+
202
+
203
+ def parts_alternates(args: dict[str, Any], ctx: ToolContext) -> dict:
204
+ """Alternates by footprint and electrical match. Suggestions only."""
205
+ from traceforge.parts.alternates import find_alternates
206
+
207
+ if not args.get("part"):
208
+ raise ToolError("give 'part' (LCSC code or MPN)")
209
+ try:
210
+ return find_alternates(str(args["part"]), limit=int(args.get("limit") or 5)).to_dict()
211
+ except Offline as exc:
212
+ raise _offline_error(exc) from exc
213
+
214
+
215
+ def sourcing_brief(args: dict[str, Any], ctx: ToolContext) -> dict:
216
+ """Cited sourcing brief for one part (HS code, distributors, risk, price bands, landed cost)."""
217
+ from traceforge.parts.brief import build_brief
218
+
219
+ if not args.get("part"):
220
+ raise ToolError("give 'part' (LCSC code or MPN)")
221
+ try:
222
+ brief = build_brief(
223
+ str(args["part"]),
224
+ country=str(args.get("country") or ""),
225
+ currency=str(args.get("currency") or "USD"),
226
+ qty=int(args.get("quantity") or 1000),
227
+ )
228
+ except Offline as exc:
229
+ raise _offline_error(exc) from exc
230
+ data = brief.to_dict()
231
+ if args.get("write"):
232
+ base = ctx.resolve(str(args["write"]), for_write=True)
233
+ base.parent.mkdir(parents=True, exist_ok=True)
234
+ base.with_suffix(".md").write_text(brief.to_markdown() + "\n", encoding="utf-8")
235
+ base.with_suffix(".json").write_text(
236
+ json.dumps(data, indent=1, default=str), encoding="utf-8"
237
+ )
238
+ data["written"] = [str(base.with_suffix(".md")), str(base.with_suffix(".json"))]
239
+ else:
240
+ data["markdown"] = brief.to_markdown()
241
+ return data
242
+
243
+
244
+ def bom_cost(args: dict[str, Any], ctx: ToolContext) -> dict:
245
+ """Priced BOM roll-up (cheapest eligible supplier per line) and optional CSV exports."""
246
+ from traceforge.parts.bom import export_bom, load_bom, rollup
247
+ from traceforge.parts.fx import load_rates
248
+ from traceforge.parts.supply import source_many
249
+
250
+ path = ctx.resolve(str(args.get("path") or "."), must_exist=True)
251
+ try:
252
+ items = load_bom(path)
253
+ except (ValueError, OSError) as exc:
254
+ raise ToolError(str(exc)) from exc
255
+ boards = _qty_list(args, "board_quantities", (1, 10, 100, 1000))
256
+ currency = str(args.get("currency") or "USD")
257
+ fx = load_rates()
258
+ try:
259
+ sourcings = source_many(
260
+ [i.query for i in items if i.query],
261
+ qtys=tuple(sorted({q * i.qty for q in boards for i in items})),
262
+ currency=currency,
263
+ fx=fx,
264
+ )
265
+ except Offline as exc:
266
+ raise _offline_error(exc) from exc
267
+ roll = rollup(items, sourcings, boards, currency.upper())
268
+ out = roll.to_dict()
269
+ out["fx"] = fx.describe()
270
+ out["markdown"] = roll.to_markdown()
271
+ if args.get("export_dir"):
272
+ folder = ctx.resolve(str(args["export_dir"]), for_write=True)
273
+ folder.mkdir(parents=True, exist_ok=True)
274
+ written = []
275
+ for fmt in ("jlcpcb", "pcbway", "generic", "excel"):
276
+ target = folder / f"bom_{fmt}.csv"
277
+ target.write_text(
278
+ export_bom(items, fmt, sourcings, boards[0]), encoding="utf-8", newline=""
279
+ )
280
+ written.append(str(target))
281
+ out["written"] = written
282
+ return out
283
+
284
+
285
+ PART_TOOLS = [
286
+ Tool(
287
+ "part_lookup",
288
+ "Look up parts live in the JLCPCB/LCSC catalogues (and Octopart/DigiKey/Mouser when "
289
+ "configured): manufacturer, MPN, package, pin count, stock, price, JLCPCB basic/"
290
+ "extended, datasheet URL, key attributes. Give an LCSC code (C2286) or MPN; 'parts' "
291
+ "checks many at once. Use it for every part number you write or review.",
292
+ {
293
+ "type": "object",
294
+ "properties": {
295
+ "part": {"type": "string"},
296
+ "parts": {"type": "array", "items": {"type": "string"}},
297
+ "full": {"type": "boolean", "description": "all attributes and price breaks"},
298
+ },
299
+ },
300
+ part_lookup,
301
+ ),
302
+ Tool(
303
+ "part_search",
304
+ "Search orderable parts by MPN prefix, value and package or keywords ('10k 0603 1%', "
305
+ "'100nF 0402', 'AMS1117-3.3', 'usb type-c 16p'). Returns JLCPCB basic parts first, then "
306
+ "by stock. Pick part numbers from these results, never from memory.",
307
+ {
308
+ "type": "object",
309
+ "properties": {
310
+ "query": {"type": "string"},
311
+ "package": {"type": "string", "description": "e.g. 0603, SOT-23-5, SOIC-8"},
312
+ "basic_only": {"type": "boolean"},
313
+ "in_stock": {"type": "boolean", "description": "default true"},
314
+ "limit": {"type": "integer"},
315
+ },
316
+ "required": ["query"],
317
+ },
318
+ part_search,
319
+ ),
320
+ Tool(
321
+ "parts_check",
322
+ "Verify every LCSC/MPN in the project against the live catalogue: part exists, package "
323
+ "fits the footprint (size, SOIC width, pin count, pitch, SMD/THT), value and MPN match, "
324
+ "stock, lifecycle, basic vs extended. Returns P0-P3 findings with evidence.",
325
+ {
326
+ "type": "object",
327
+ "properties": {"schematic": {"type": "string"}, "pcb": {"type": "string"}},
328
+ },
329
+ parts_check,
330
+ ),
331
+ Tool(
332
+ "parts_verify",
333
+ "Verify every BOM line (part exists, package vs footprint, pin count, datasheet pinout "
334
+ "vs symbol pins and footprint pads, stock, basic/extended) and write "
335
+ ".traceforge/reports/parts-verification.md/.json. The design is not finished until "
336
+ "this reports 'all parts verified'.",
337
+ {
338
+ "type": "object",
339
+ "properties": {"path": {"type": "string", "description": "project folder"}},
340
+ },
341
+ parts_verify,
342
+ ),
343
+ Tool(
344
+ "datasheet_fetch",
345
+ "Download a datasheet (by url, by part via its catalogue datasheet, or a local PDF path) "
346
+ "to .traceforge/datasheets/ and return text: the pages you ask for ('1-3,12'), the pages "
347
+ "that best match 'query' (e.g. 'package dimensions 8S2', 'absolute maximum', 'typical "
348
+ "application'), or by default the first pages and the outline.",
349
+ {
350
+ "type": "object",
351
+ "properties": {
352
+ "url": {"type": "string"},
353
+ "part": {"type": "string", "description": "LCSC code or MPN"},
354
+ "path": {"type": "string", "description": "a PDF already in the workspace"},
355
+ "pages": {"type": "string"},
356
+ "query": {"type": "string"},
357
+ "max_chars": {"type": "integer"},
358
+ },
359
+ },
360
+ datasheet_fetch,
361
+ ),
362
+ Tool(
363
+ "sourcing_compare",
364
+ "Merged supplier comparison for one part across every configured source (JLCPCB, LCSC, "
365
+ "jlcsearch, plus DigiKey/Mouser/Nexar when keyed): price breaks, stock, MOQ, lead time, "
366
+ "lifecycle, best price per quantity and a recommended supplier, in one currency with the "
367
+ "exchange-rate table named.",
368
+ {
369
+ "type": "object",
370
+ "properties": {
371
+ "part": {"type": "string", "description": "LCSC code or MPN"},
372
+ "quantities": {"type": "array", "items": {"type": "integer"}},
373
+ "currency": {"type": "string"},
374
+ "country": {"type": "string"},
375
+ },
376
+ "required": ["part"],
377
+ },
378
+ sourcing_compare,
379
+ ),
380
+ Tool(
381
+ "parts_alternates",
382
+ "Alternates by footprint and electrical match (passives: value, tolerance, rating, "
383
+ "package, dielectric; MCUs: pin-compatible family suggestions with confidence). Each "
384
+ "carries reasons and differences. Suggestions only: never substitute without telling the user.",
385
+ {
386
+ "type": "object",
387
+ "properties": {"part": {"type": "string"}, "limit": {"type": "integer"}},
388
+ "required": ["part"],
389
+ },
390
+ parts_alternates,
391
+ ),
392
+ Tool(
393
+ "sourcing_brief",
394
+ "Cited sourcing brief for one part: suppliers, HS code with rationale (a guide to confirm "
395
+ "with the customs broker), authorised distributors, single-source risk, volume price bands "
396
+ "and a landed-cost estimate with the arithmetic. Pass 'write' (path without extension) to "
397
+ "save Markdown and JSON.",
398
+ {
399
+ "type": "object",
400
+ "properties": {
401
+ "part": {"type": "string"},
402
+ "country": {"type": "string", "description": "destination, e.g. IN"},
403
+ "quantity": {"type": "integer"},
404
+ "currency": {"type": "string"},
405
+ "write": {"type": "string"},
406
+ },
407
+ "required": ["part"],
408
+ },
409
+ sourcing_brief,
410
+ ToolKind.WRITE,
411
+ "write",
412
+ ),
413
+ Tool(
414
+ "bom_cost",
415
+ "Price a whole BOM (design.yaml, KiCad schematic, project folder or BOM CSV): cheapest "
416
+ "eligible supplier per line and totals at 1/10/100/1000 boards. 'export_dir' also writes "
417
+ "JLCPCB, PCBWay, generic and Excel CSV BOMs.",
418
+ {
419
+ "type": "object",
420
+ "properties": {
421
+ "path": {"type": "string"},
422
+ "board_quantities": {"type": "array", "items": {"type": "integer"}},
423
+ "currency": {"type": "string"},
424
+ "export_dir": {"type": "string"},
425
+ },
426
+ },
427
+ bom_cost,
428
+ ToolKind.WRITE,
429
+ "export_dir",
430
+ ),
431
+ Tool(
432
+ "web_search",
433
+ "Search the web (read-only): app notes, errata, reference values, part comparisons. "
434
+ "Returns titles, URLs, snippets and, for some providers, a cited answer.",
435
+ {
436
+ "type": "object",
437
+ "properties": {"query": {"type": "string"}, "limit": {"type": "integer"}},
438
+ "required": ["query"],
439
+ },
440
+ web_search_tool,
441
+ ),
442
+ Tool(
443
+ "web_fetch",
444
+ "Fetch a public web page or PDF as plain text (read-only). Use 'find' to return only "
445
+ "the passages around a phrase, or offset/max_chars to page through long documents.",
446
+ {
447
+ "type": "object",
448
+ "properties": {
449
+ "url": {"type": "string"},
450
+ "find": {"type": "string"},
451
+ "offset": {"type": "integer"},
452
+ "max_chars": {"type": "integer"},
453
+ },
454
+ "required": ["url"],
455
+ },
456
+ web_fetch_tool,
457
+ ),
458
+ Tool(
459
+ "reference_project_search",
460
+ "Find open-source reference designs (GitHub, OSHWLab, Hackaday.io) that use a part or "
461
+ "implement a circuit block, to compare against proven schematics and values.",
462
+ {
463
+ "type": "object",
464
+ "properties": {
465
+ "query": {"type": "string", "description": "e.g. 'ATtiny85 USB-C', 'CH340C'"},
466
+ "tool": {"type": "string", "enum": ["kicad", "altium", "any"]},
467
+ "limit": {"type": "integer"},
468
+ },
469
+ "required": ["query"],
470
+ },
471
+ reference_projects,
472
+ ),
473
+ ]