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.
- traceforge/__init__.py +3 -0
- traceforge/__main__.py +3 -0
- traceforge/acp_server.py +570 -0
- traceforge/agent.py +680 -0
- traceforge/altium/__init__.py +6 -0
- traceforge/altium/cfb.py +70 -0
- traceforge/altium/cli.py +60 -0
- traceforge/altium/model.py +163 -0
- traceforge/altium/outputs.py +24 -0
- traceforge/altium/pcb.py +301 -0
- traceforge/altium/project.py +328 -0
- traceforge/altium/schematic.py +400 -0
- traceforge/altium/tools.py +166 -0
- traceforge/approval.py +219 -0
- traceforge/attachments.py +346 -0
- traceforge/background.py +253 -0
- traceforge/bench/__init__.py +5 -0
- traceforge/bench/adapters.py +337 -0
- traceforge/bench/checks.py +410 -0
- traceforge/bench/cli.py +121 -0
- traceforge/bench/design.py +439 -0
- traceforge/bench/kicad.py +99 -0
- traceforge/bench/partscheck.py +159 -0
- traceforge/bench/review.py +100 -0
- traceforge/bench/runner.py +226 -0
- traceforge/bench/score.py +204 -0
- traceforge/bench/tasks.py +102 -0
- traceforge/chat.py +1185 -0
- traceforge/checks.py +328 -0
- traceforge/checks_sides.py +118 -0
- traceforge/cli.py +2224 -0
- traceforge/clipboard.py +107 -0
- traceforge/config.py +299 -0
- traceforge/context.py +276 -0
- traceforge/design/__init__.py +1 -0
- traceforge/design/constraints.py +397 -0
- traceforge/design/generate.py +1789 -0
- traceforge/design/outline.py +905 -0
- traceforge/design/schematic_layout.py +932 -0
- traceforge/design/schematic_lint.py +329 -0
- traceforge/design/schematic_route.py +207 -0
- traceforge/design/sheets.py +854 -0
- traceforge/design/sides.py +328 -0
- traceforge/design/spec.py +462 -0
- traceforge/design/specsheet.py +2351 -0
- traceforge/design/specsheet_render.py +183 -0
- traceforge/desktop/__init__.py +109 -0
- traceforge/desktop/keys.py +90 -0
- traceforge/desktop/project.py +443 -0
- traceforge/desktop/review.py +20 -0
- traceforge/desktop/server.py +1277 -0
- traceforge/desktop/static/app.css +223 -0
- traceforge/desktop/static/app.js +683 -0
- traceforge/desktop/static/highlight.js +77 -0
- traceforge/desktop/static/index.html +89 -0
- traceforge/desktop/static/md.js +90 -0
- traceforge/desktop/static/viewer.js +213 -0
- traceforge/desktop/symbols.py +212 -0
- traceforge/desktop/update.py +65 -0
- traceforge/diagnostics.py +67 -0
- traceforge/errata.py +219 -0
- traceforge/fab.py +108 -0
- traceforge/fab_layers.py +44 -0
- traceforge/features/__init__.py +6 -0
- traceforge/features/candidates.py +117 -0
- traceforge/features/cli.py +200 -0
- traceforge/features/package.py +433 -0
- traceforge/features/revdiff.py +224 -0
- traceforge/features/sarif.py +145 -0
- traceforge/features/viewer.py +265 -0
- traceforge/firmware/__init__.py +3 -0
- traceforge/firmware/cli.py +174 -0
- traceforge/firmware/drivers.py +372 -0
- traceforge/firmware/drivers_data.py +256 -0
- traceforge/firmware/mcus.py +177 -0
- traceforge/firmware/netlist.py +55 -0
- traceforge/firmware/pinmap.py +296 -0
- traceforge/firmware/scaffold.py +466 -0
- traceforge/firmware/toolchain.py +262 -0
- traceforge/hooks.py +237 -0
- traceforge/kicad/__init__.py +0 -0
- traceforge/kicad/cli.py +519 -0
- traceforge/kicad/diffpair.py +174 -0
- traceforge/kicad/edgegeom.py +191 -0
- traceforge/kicad/edit.py +405 -0
- traceforge/kicad/escape.py +1410 -0
- traceforge/kicad/formats.py +1073 -0
- traceforge/kicad/impedance.py +320 -0
- traceforge/kicad/install.py +75 -0
- traceforge/kicad/libraries.py +490 -0
- traceforge/kicad/models3d.py +942 -0
- traceforge/kicad/outputs.py +81 -0
- traceforge/kicad/pcb.py +787 -0
- traceforge/kicad/planes.py +1242 -0
- traceforge/kicad/project.py +136 -0
- traceforge/kicad/route.py +2698 -0
- traceforge/kicad/routegeom.py +164 -0
- traceforge/kicad/schematic.py +268 -0
- traceforge/kicad/sides.py +34 -0
- traceforge/kicad/stackup.py +424 -0
- traceforge/kicad_plugin/__init__.py +12 -0
- traceforge/kicad_plugin/actions.py +92 -0
- traceforge/kicad_plugin/cli.py +66 -0
- traceforge/kicad_plugin/client.py +86 -0
- traceforge/kicad_plugin/context.py +257 -0
- traceforge/kicad_plugin/discovery.py +81 -0
- traceforge/kicad_plugin/install.py +203 -0
- traceforge/kicad_plugin/overlay.py +60 -0
- traceforge/kicad_plugin/payload/action_plugin.py +65 -0
- traceforge/kicad_plugin/payload/ipc_main.py +42 -0
- traceforge/kicad_plugin/payload/plugin.json +90 -0
- traceforge/kicad_plugin/payload/requirements.txt +1 -0
- traceforge/kicad_plugin/views.py +202 -0
- traceforge/langchain.py +57 -0
- traceforge/local.py +580 -0
- traceforge/mcp_server.py +304 -0
- traceforge/models.py +310 -0
- traceforge/next_steps.py +110 -0
- traceforge/parts/__init__.py +52 -0
- traceforge/parts/alternates.py +488 -0
- traceforge/parts/bom.py +423 -0
- traceforge/parts/brief.py +705 -0
- traceforge/parts/catalog.py +297 -0
- traceforge/parts/cli_sourcing.py +156 -0
- traceforge/parts/data/duty_rates.yaml +37 -0
- traceforge/parts/data/hs_codes.yaml +103 -0
- traceforge/parts/datasheet.py +278 -0
- traceforge/parts/fx.py +100 -0
- traceforge/parts/model.py +185 -0
- traceforge/parts/net.py +187 -0
- traceforge/parts/offers.py +46 -0
- traceforge/parts/packages.py +397 -0
- traceforge/parts/pinout.py +714 -0
- traceforge/parts/providers/__init__.py +17 -0
- traceforge/parts/providers/base.py +100 -0
- traceforge/parts/providers/jlc.py +364 -0
- traceforge/parts/providers/keyed.py +661 -0
- traceforge/parts/report.py +508 -0
- traceforge/parts/supply.py +330 -0
- traceforge/parts/verify.py +455 -0
- traceforge/parts/web.py +575 -0
- traceforge/plans.py +70 -0
- traceforge/preferences.py +127 -0
- traceforge/prices.py +130 -0
- traceforge/prices.yaml +28 -0
- traceforge/prompts/__init__.py +54 -0
- traceforge/prompts/system.md +103 -0
- traceforge/providers.py +764 -0
- traceforge/remote/__init__.py +33 -0
- traceforge/remote/client.py +422 -0
- traceforge/remote/oidc.py +240 -0
- traceforge/remote/orgs.py +510 -0
- traceforge/remote/server.py +690 -0
- traceforge/remote/store.py +100 -0
- traceforge/review_changes.py +187 -0
- traceforge/routing.py +99 -0
- traceforge/runtime.py +435 -0
- traceforge/sessions.py +324 -0
- traceforge/sexpr.py +279 -0
- traceforge/sim/__init__.py +17 -0
- traceforge/sim/acmath.py +234 -0
- traceforge/sim/circuit.py +499 -0
- traceforge/sim/cli.py +162 -0
- traceforge/sim/engine.py +172 -0
- traceforge/sim/ltspice.py +175 -0
- traceforge/sim/measure.py +123 -0
- traceforge/sim/models.py +76 -0
- traceforge/sim/project.py +218 -0
- traceforge/sim/rawfile.py +123 -0
- traceforge/sim/results.py +298 -0
- traceforge/sim/tune.py +172 -0
- traceforge/skill_commands.py +165 -0
- traceforge/skill_library/circuit-simulation/SKILL.md +30 -0
- traceforge/skill_library/compliance-review/SKILL.md +51 -0
- traceforge/skill_library/design-review/SKILL.md +47 -0
- traceforge/skill_library/errata-hunt/SKILL.md +30 -0
- traceforge/skill_library/firmware/SKILL.md +26 -0
- traceforge/skill_library/power-budget/SKILL.md +27 -0
- traceforge/skill_library/release-package/SKILL.md +29 -0
- traceforge/skill_library/sourcing-brief/SKILL.md +24 -0
- traceforge/skill_library/thermal-review/SKILL.md +15 -0
- traceforge/skills.py +131 -0
- traceforge/thermal.py +557 -0
- traceforge/tools/__init__.py +48 -0
- traceforge/tools/agent_tools.py +213 -0
- traceforge/tools/base.py +303 -0
- traceforge/tools/batch.py +155 -0
- traceforge/tools/connections.py +245 -0
- traceforge/tools/files.py +777 -0
- traceforge/tools/firmware_tools.py +218 -0
- traceforge/tools/kicad_tools.py +1072 -0
- traceforge/tools/models3d_tools.py +141 -0
- traceforge/tools/parts_tools.py +473 -0
- traceforge/tools/processes.py +246 -0
- traceforge/tools/review_tools.py +178 -0
- traceforge/tools/sim_tools.py +230 -0
- traceforge/tui.py +620 -0
- traceforge/updates.py +124 -0
- traceforge/validate/__init__.py +77 -0
- traceforge/validate/builtins.py +1091 -0
- traceforge/validate/circuits.py +1240 -0
- traceforge/validate/config.py +410 -0
- traceforge/validate/default.yaml +113 -0
- traceforge/validate/design.py +191 -0
- traceforge/validate/explain.py +112 -0
- traceforge/validate/grounding.py +169 -0
- traceforge/validate/ipc2221.py +350 -0
- traceforge/validate/multilayer.py +805 -0
- traceforge/validate/netmodel.py +261 -0
- traceforge/validate/partcheck.py +472 -0
- traceforge/validate/report.py +280 -0
- traceforge/validate/review.py +322 -0
- traceforge/validate/runner.py +662 -0
- traceforge/workspace.py +171 -0
- traceforge_pcb-1.0.0.dist-info/METADATA +463 -0
- traceforge_pcb-1.0.0.dist-info/RECORD +219 -0
- traceforge_pcb-1.0.0.dist-info/WHEEL +4 -0
- traceforge_pcb-1.0.0.dist-info/entry_points.txt +2 -0
- 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")
|