cfdpilot 0.1.1__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.
cfdpilot/__init__.py ADDED
File without changes
cfdpilot/agent.py ADDED
@@ -0,0 +1,299 @@
1
+ import json
2
+ from cfdpilot.client import CFDPilotClient
3
+ from cfdpilot.tools.filesystem import read_file, list_dir
4
+ from cfdpilot.tools.log_parser import parse_log
5
+ from cfdpilot.tools.patcher import patch_dict, generate_diff
6
+ from cfdpilot.tools.runner import run_command, run_solver
7
+
8
+ MAX_ATTEMPTS = 3
9
+
10
+ TOOL_SCHEMAS = [
11
+ {
12
+ "name": "read_file",
13
+ "description": "Read a file from the OpenFOAM case directory",
14
+ "input_schema": {
15
+ "type": "object",
16
+ "properties": {"path": {"type": "string"}},
17
+ "required": ["path"]
18
+ }
19
+ },
20
+ {
21
+ "name": "list_dir",
22
+ "description": "List files in a directory",
23
+ "input_schema": {
24
+ "type": "object",
25
+ "properties": {"path": {"type": "string", "default": "."}},
26
+ "required": []
27
+ }
28
+ },
29
+ {
30
+ "name": "parse_log",
31
+ "description": "Parse an OpenFOAM log file. Returns structured JSON with residuals, Co number, divergence info.",
32
+ "input_schema": {
33
+ "type": "object",
34
+ "properties": {"log_path": {"type": "string", "description": "Path to log file, or omit to auto-detect"}},
35
+ "required": []
36
+ }
37
+ },
38
+ {
39
+ "name": "run_command",
40
+ "description": "Run an OpenFOAM command (blockMesh, checkMesh, foamDictionary, etc.). NOT for the main solver.",
41
+ "input_schema": {
42
+ "type": "object",
43
+ "properties": {"cmd": {"type": "string"}},
44
+ "required": ["cmd"]
45
+ }
46
+ },
47
+ {
48
+ "name": "run_solver",
49
+ "description": "Launch the OpenFOAM solver and stream residuals. Auto-stops on convergence or divergence.",
50
+ "input_schema": {
51
+ "type": "object",
52
+ "properties": {
53
+ "solver": {"type": "string"},
54
+ "max_iterations": {"type": "integer"}
55
+ },
56
+ "required": ["solver"]
57
+ }
58
+ },
59
+ {
60
+ "name": "show_diff",
61
+ "description": "Show a colored diff of proposed changes to a file. ALWAYS call this before patch_dict.",
62
+ "input_schema": {
63
+ "type": "object",
64
+ "properties": {
65
+ "file_path": {"type": "string"},
66
+ "proposed_content": {"type": "string"}
67
+ },
68
+ "required": ["file_path", "proposed_content"]
69
+ }
70
+ },
71
+ {
72
+ "name": "patch_dict",
73
+ "description": "Apply confirmed changes to an OpenFOAM dict file. Creates .bak backup automatically.",
74
+ "input_schema": {
75
+ "type": "object",
76
+ "properties": {
77
+ "file_path": {"type": "string"},
78
+ "changes": {"type": "object", "description": "key-value pairs to update"}
79
+ },
80
+ "required": ["file_path", "changes"]
81
+ }
82
+ },
83
+ {
84
+ "name": "search_knowledge",
85
+ "description": "Search the CFDpilot knowledge base: 1,000+ CFD-Online threads + OpenFOAM user guide. Use when you need community experience or documentation on a specific OF topic.",
86
+ "input_schema": {
87
+ "type": "object",
88
+ "properties": {"query": {"type": "string", "description": "What to search for, e.g. 'GAMG divergence kOmegaSST' or 'nOuterCorrectors pimpleFoam'"}},
89
+ "required": ["query"]
90
+ }
91
+ },
92
+ ]
93
+
94
+
95
+ def _dispatch_tool(name: str, inputs: dict, cwd: str, display, client=None) -> str:
96
+ """Execute a tool call and return result as JSON string."""
97
+ try:
98
+ if name == "read_file":
99
+ result = read_file(inputs["path"], cwd=cwd)
100
+ return json.dumps(result)
101
+
102
+ elif name == "list_dir":
103
+ result = list_dir(inputs.get("path", "."), cwd=cwd)
104
+ return json.dumps(result)
105
+
106
+ elif name == "parse_log":
107
+ result = parse_log(inputs.get("log_path"), cwd=cwd)
108
+ return json.dumps(result)
109
+
110
+ elif name == "run_command":
111
+ display.print_action(f"Running: {inputs['cmd']}")
112
+ result = run_command(inputs["cmd"], cwd=cwd)
113
+ return json.dumps(result)
114
+
115
+ elif name == "run_solver":
116
+ display.print_action(f"Launching {inputs['solver']}...")
117
+ result = run_solver(
118
+ inputs["solver"],
119
+ cwd=cwd,
120
+ max_iterations=inputs.get("max_iterations"),
121
+ on_line=display.print_solver_line
122
+ )
123
+ return json.dumps(result)
124
+
125
+ elif name == "show_diff":
126
+ diff = generate_diff(inputs["file_path"], inputs["proposed_content"])
127
+ display.print_diff(diff)
128
+ confirmed = display.confirm("Apply changes?")
129
+ return json.dumps({"diff": diff, "confirmed": confirmed})
130
+
131
+ elif name == "patch_dict":
132
+ display.print_action(f"Patching {inputs['file_path']}")
133
+ try:
134
+ result = patch_dict(inputs["file_path"], inputs["changes"])
135
+ display.print_action(f"Backup saved → {result['backup']}")
136
+ return json.dumps(result)
137
+ except FileNotFoundError as e:
138
+ return json.dumps({"success": False, "error": str(e)})
139
+
140
+ elif name == "search_knowledge":
141
+ query = inputs["query"]
142
+ display.print_action(f"Searching knowledge base: {query}")
143
+ results = client.search(query) if client else []
144
+ return json.dumps({"results": results, "count": len(results)})
145
+
146
+ return json.dumps({"error": f"Unknown tool: {name}"})
147
+
148
+ except KeyError as e:
149
+ return json.dumps({"error": f"Missing required input: {e}"})
150
+ except Exception as e:
151
+ return json.dumps({"error": f"Tool error: {type(e).__name__}: {e}"})
152
+
153
+
154
+ SESSION_TOKEN_CAP = 100_000
155
+
156
+
157
+ def _build_case_context(cwd: str, case_info: dict) -> str:
158
+ """Build a context block injected as the first user message."""
159
+ import os
160
+ lines = [f"Working directory: {cwd}"]
161
+ if case_info.get("flat_structure"):
162
+ files = ", ".join(case_info.get("fields", [])) or "unknown"
163
+ lines.append("Structure: FLAT (files dumped in one directory — no 0/constant/system hierarchy)")
164
+ lines.append(f"Files present: {files}")
165
+ lines.append("Note: read files directly by name (e.g. read_file('U'), read_file('fvSchemes'))")
166
+ else:
167
+ lines.append("Structure: standard OpenFOAM (0/ constant/ system/)")
168
+ if case_info.get("solver") and case_info["solver"] != "unknown":
169
+ lines.append(f"Solver: {case_info['solver']}")
170
+ if case_info.get("turbulence_model") and case_info["turbulence_model"] != "unknown":
171
+ lines.append(f"Turbulence: {case_info['turbulence_model']}")
172
+ if case_info.get("log_file"):
173
+ lines.append(f"Log file: {case_info['log_file']}")
174
+ else:
175
+ lines.append("Log file: none found")
176
+ return "\n".join(lines)
177
+
178
+
179
+ def run_agent_loop(token: str, cwd: str, display, case_info: dict = None) -> None:
180
+ """Main conversation loop. Runs until user exits."""
181
+ client = CFDPilotClient(token)
182
+ messages = []
183
+ session_tokens = 0
184
+
185
+ # Inject case context as a silent system message (not shown to user)
186
+ if case_info:
187
+ ctx = _build_case_context(cwd, case_info)
188
+ messages.append({
189
+ "role": "user",
190
+ "content": f"[CASE CONTEXT — read this before answering anything]\n{ctx}"
191
+ })
192
+ messages.append({
193
+ "role": "assistant",
194
+ "content": [{"type": "text", "text": "Case context noted. Ready."}]
195
+ })
196
+
197
+ display.print_welcome()
198
+
199
+ while True:
200
+ try:
201
+ user_input = display.get_input()
202
+ except (KeyboardInterrupt, EOFError):
203
+ display.print_goodbye()
204
+ break
205
+
206
+ if user_input.lower() in ("exit", "quit", "q"):
207
+ display.print_goodbye()
208
+ break
209
+
210
+ if not user_input.strip():
211
+ continue
212
+
213
+ messages.append({"role": "user", "content": user_input})
214
+ attempt_count = 0 # Reset per user turn
215
+
216
+ # Agentic inner loop: keep going until no more tool calls
217
+ while True:
218
+ if session_tokens >= SESSION_TOKEN_CAP:
219
+ display.print_error(
220
+ f"Session limit reached ({session_tokens:,} tokens). "
221
+ "Start a new session: cfdpilot"
222
+ )
223
+ return
224
+
225
+ try:
226
+ response = client.chat(messages, TOOL_SCHEMAS)
227
+ except RuntimeError as e:
228
+ display.print_error(str(e))
229
+ break
230
+
231
+ usage = response.get("usage", {})
232
+ session_tokens += (
233
+ usage.get("input_tokens", 0)
234
+ + usage.get("output_tokens", 0)
235
+ + usage.get("cache_read_input_tokens", 0)
236
+ + usage.get("cache_creation_input_tokens", 0)
237
+ )
238
+
239
+ content = response.get("content", [])
240
+ stop_reason = response.get("stop_reason", "end_turn")
241
+
242
+ # Display text blocks
243
+ for block in content:
244
+ if block.get("type") == "text":
245
+ display.print_text(block["text"])
246
+
247
+ # Add assistant turn to history
248
+ messages.append({"role": "assistant", "content": content})
249
+
250
+ # No tool calls → turn done
251
+ if stop_reason != "tool_use":
252
+ break
253
+
254
+ # Extract and execute tool calls
255
+ tool_results = []
256
+ stuck = False
257
+ for block in content:
258
+ if block.get("type") != "tool_use":
259
+ continue
260
+
261
+ tool_name = block["name"]
262
+ tool_inputs = block.get("input", {})
263
+ tool_id = block["id"]
264
+
265
+ display.print_action(f"● {tool_name}...")
266
+
267
+ # STUCK tracking
268
+ if tool_name == "patch_dict":
269
+ attempt_count += 1
270
+ if attempt_count > MAX_ATTEMPTS:
271
+ display.print_stuck(attempt_count)
272
+ tool_results.append({
273
+ "type": "tool_result",
274
+ "tool_use_id": tool_id,
275
+ "content": json.dumps({"error": "MAX_ATTEMPTS_REACHED"})
276
+ })
277
+ stuck = True
278
+ continue
279
+
280
+ result = _dispatch_tool(tool_name, tool_inputs, cwd, display, client)
281
+ tool_results.append({
282
+ "type": "tool_result",
283
+ "tool_use_id": tool_id,
284
+ "content": result # full result sent to model this turn
285
+ })
286
+
287
+ # Truncate tool results before storing in history to prevent context bloat.
288
+ # The model already processed the full content this turn.
289
+ MAX_TOOL_RESULT_CHARS = 1500
290
+ history_tool_results = []
291
+ for tr in tool_results:
292
+ content = tr["content"]
293
+ if len(content) > MAX_TOOL_RESULT_CHARS:
294
+ content = content[:MAX_TOOL_RESULT_CHARS] + "…[truncated]"
295
+ history_tool_results.append({**tr, "content": content})
296
+ messages.append({"role": "user", "content": history_tool_results})
297
+
298
+ if stuck:
299
+ break
cfdpilot/auth.py ADDED
@@ -0,0 +1,57 @@
1
+ import json
2
+ import time
3
+ import httpx
4
+ from pathlib import Path
5
+
6
+ TOKEN_PATH = Path.home() / ".cfdpilot" / "config"
7
+ API_BASE = "https://cfdpilot.com"
8
+ POLL_INTERVAL = 2
9
+ POLL_TIMEOUT = 600
10
+
11
+
12
+ def save_token(token: str) -> None:
13
+ TOKEN_PATH.parent.mkdir(parents=True, exist_ok=True)
14
+ TOKEN_PATH.write_text(json.dumps({"token": token}))
15
+ TOKEN_PATH.chmod(0o600)
16
+
17
+
18
+ def load_token() -> str | None:
19
+ if not TOKEN_PATH.exists():
20
+ return None
21
+ try:
22
+ return json.loads(TOKEN_PATH.read_text()).get("token")
23
+ except Exception:
24
+ return None
25
+
26
+
27
+ def device_login() -> str:
28
+ """Run device flow. Blocks until authorized or timeout."""
29
+ try:
30
+ with httpx.Client(timeout=10) as client:
31
+ resp = client.post(f"{API_BASE}/device/code")
32
+ resp.raise_for_status()
33
+ data = resp.json()
34
+ except Exception as e:
35
+ raise ConnectionError(f"Could not reach cfdpilot.com: {e}") from e
36
+
37
+ code = data["code"]
38
+ url = data["url"]
39
+ print(f"\n Open this URL in your browser:\n {url}\n")
40
+ print(" Waiting for authentication", end="", flush=True)
41
+
42
+ deadline = time.time() + POLL_TIMEOUT
43
+ with httpx.Client() as client:
44
+ while time.time() < deadline:
45
+ time.sleep(POLL_INTERVAL)
46
+ print(".", end="", flush=True)
47
+ try:
48
+ resp = client.get(f"{API_BASE}/device/poll/{code}")
49
+ if resp.status_code == 200:
50
+ data = resp.json()
51
+ if data["status"] == "authorized":
52
+ print(" ✓")
53
+ return data["token"]
54
+ except Exception:
55
+ continue
56
+
57
+ raise TimeoutError("Authentication timed out. Run `cfdpilot login` again.")
@@ -0,0 +1,90 @@
1
+ import re
2
+ from pathlib import Path
3
+ from cfdpilot.tools.log_parser import _find_log
4
+
5
+ SOLVER_PATTERN = re.compile(r"application\s+(\w+);")
6
+ TURBULENCE_PATTERN = re.compile(r"RASModel\s+(\w+);|LESModel\s+(\w+);|model\s+(\w+);")
7
+ DELTA_T_PATTERN = re.compile(r"deltaT\s+([\d.eE+-]+);")
8
+ END_TIME_PATTERN = re.compile(r"endTime\s+([\d.eE+-]+);")
9
+
10
+
11
+ _OF_FLAT_MARKERS = {"controlDict", "fvSchemes", "fvSolution", "turbulenceProperties", "momentumTransport"}
12
+ _OF_BC_FILES = {"U", "p", "T", "k", "omega", "epsilon", "nut", "alphat", "nuTilda", "p_rgh"}
13
+
14
+
15
+ def detect_case(cwd: str) -> dict:
16
+ base = Path(cwd)
17
+ required = [base / "system" / "controlDict", base / "constant", base / "0"]
18
+
19
+ # Standard OF structure
20
+ if all(p.exists() for p in required):
21
+ pass # proceed below
22
+ else:
23
+ # Flat structure: files dumped in one directory (forum thread, shared files)
24
+ flat_files = {f.name for f in base.iterdir() if f.is_file()}
25
+ has_markers = bool(flat_files & _OF_FLAT_MARKERS)
26
+ has_bc = bool(flat_files & _OF_BC_FILES)
27
+ if not (has_markers or has_bc):
28
+ return {"is_of_case": False}
29
+ # Flat case detected — build result directly
30
+ result = {"is_of_case": True, "cwd": cwd, "flat_structure": True}
31
+ ctrl_path = base / "controlDict"
32
+ if ctrl_path.exists():
33
+ ctrl = ctrl_path.read_text(errors="replace")
34
+ m = SOLVER_PATTERN.search(ctrl)
35
+ result["solver"] = m.group(1) if m else "unknown"
36
+ m = DELTA_T_PATTERN.search(ctrl)
37
+ result["deltaT"] = float(m.group(1)) if m else None
38
+ m = END_TIME_PATTERN.search(ctrl)
39
+ result["endTime"] = float(m.group(1)) if m else None
40
+ else:
41
+ result["solver"] = "unknown"
42
+ for turb_name in ("turbulenceProperties", "momentumTransport"):
43
+ turb_path = base / turb_name
44
+ if turb_path.exists():
45
+ turb = turb_path.read_text(errors="replace")
46
+ m = TURBULENCE_PATTERN.search(turb)
47
+ result["turbulence_model"] = next((g for g in m.groups() if g), "unknown") if m else "unknown"
48
+ result["simulation_type"] = "LES" if "LESModel" in turb else "RAS"
49
+ break
50
+ else:
51
+ result["turbulence_model"] = "unknown"
52
+ result["mesh_type"] = "unknown"
53
+ result["log_file"] = None
54
+ result["fields"] = sorted(flat_files & _OF_BC_FILES)
55
+ return result
56
+
57
+ result = {"is_of_case": True, "cwd": cwd}
58
+
59
+ ctrl = (base / "system" / "controlDict").read_text(errors="replace")
60
+ m = SOLVER_PATTERN.search(ctrl)
61
+ result["solver"] = m.group(1) if m else "unknown"
62
+
63
+ m = DELTA_T_PATTERN.search(ctrl)
64
+ result["deltaT"] = float(m.group(1)) if m else None
65
+
66
+ m = END_TIME_PATTERN.search(ctrl)
67
+ result["endTime"] = float(m.group(1)) if m else None
68
+
69
+ turb_file = base / "constant" / "turbulenceProperties"
70
+ if not turb_file.exists():
71
+ turb_file = base / "constant" / "momentumTransport"
72
+ result["turbulence_model"] = "unknown"
73
+ if turb_file.exists():
74
+ turb = turb_file.read_text(errors="replace")
75
+ m = TURBULENCE_PATTERN.search(turb)
76
+ if m:
77
+ result["turbulence_model"] = next(g for g in m.groups() if g)
78
+ result["simulation_type"] = "LES" if "LESModel" in turb else "RAS"
79
+
80
+ result["mesh_type"] = "blockMesh"
81
+ if (base / "system" / "snappyHexMeshDict").exists():
82
+ result["mesh_type"] = "snappyHexMesh"
83
+
84
+ log = _find_log(cwd)
85
+ result["log_file"] = str(log) if log else None
86
+
87
+ fields = list((base / "0").iterdir())
88
+ result["fields"] = [f.name for f in fields if f.is_file()]
89
+
90
+ return result
cfdpilot/client.py ADDED
@@ -0,0 +1,51 @@
1
+ import json
2
+ import uuid
3
+ import httpx
4
+
5
+ API_BASE = "https://cfdpilot.com"
6
+
7
+
8
+ class CFDPilotClient:
9
+ def __init__(self, token: str):
10
+ self.token = token
11
+ self.session_id = str(uuid.uuid4())
12
+ self.headers = {
13
+ "Authorization": f"Bearer {token}",
14
+ "Content-Type": "application/json",
15
+ }
16
+
17
+ def search(self, query: str) -> list[dict]:
18
+ """GET /agent/search — query the RAG knowledge base."""
19
+ try:
20
+ with httpx.Client(timeout=30) as client:
21
+ response = client.get(
22
+ f"{API_BASE}/agent/search",
23
+ headers=self.headers,
24
+ params={"query": query},
25
+ )
26
+ response.raise_for_status()
27
+ return response.json().get("results", [])
28
+ except Exception:
29
+ return []
30
+
31
+ def chat(self, messages: list[dict], tools: list[dict]) -> dict:
32
+ """POST to /agent/chat, return the response dict."""
33
+ try:
34
+ with httpx.Client(timeout=120) as client:
35
+ response = client.post(
36
+ f"{API_BASE}/agent/chat",
37
+ headers=self.headers,
38
+ json={"messages": messages, "tools": tools, "session_id": self.session_id},
39
+ )
40
+ if response.status_code == 429:
41
+ raise RuntimeError("Session token limit reached. Start a new cfdpilot session.")
42
+ if response.status_code == 401:
43
+ raise RuntimeError("Invalid token. Run: cfdpilot login")
44
+ response.raise_for_status()
45
+ return response.json()
46
+ except httpx.TimeoutException:
47
+ raise RuntimeError("Request timed out. The backend may be overloaded.")
48
+ except httpx.HTTPStatusError as e:
49
+ raise RuntimeError(f"Backend error: HTTP {e.response.status_code}")
50
+ except httpx.NetworkError as e:
51
+ raise RuntimeError(f"Network error: {e}")
cfdpilot/display.py ADDED
@@ -0,0 +1,71 @@
1
+ from rich.console import Console
2
+ from rich.syntax import Syntax
3
+ from rich.panel import Panel
4
+
5
+ console = Console()
6
+
7
+
8
+ class Display:
9
+ def __init__(self, case_info: dict):
10
+ self.case_info = case_info
11
+
12
+ def print_welcome(self):
13
+ info = self.case_info
14
+ console.print()
15
+ console.print("[bold]CFDPilot Agent v0.1[/bold]")
16
+ console.rule()
17
+ if info.get("is_of_case"):
18
+ solver = info.get("solver", "unknown")
19
+ turb = info.get("turbulence_model", "unknown")
20
+ mesh = info.get("mesh_type", "unknown")
21
+ log = info.get("log_file")
22
+ console.print(
23
+ f"Case: [cyan]{solver}[/cyan] · [cyan]{turb}[/cyan] · [cyan]{mesh}[/cyan]"
24
+ )
25
+ if log:
26
+ console.print(f"Log: [dim]{log}[/dim]")
27
+ else:
28
+ console.print("[yellow]No OpenFOAM case detected in this directory[/yellow]")
29
+ console.rule()
30
+ console.print()
31
+
32
+ def get_input(self) -> str:
33
+ return console.input("[bold cyan]>[/bold cyan] ").strip()
34
+
35
+ def print_text(self, text: str):
36
+ console.print(f" {text}")
37
+
38
+ def print_action(self, msg: str):
39
+ console.print(f"[dim]● {msg}[/dim]")
40
+
41
+ def print_solver_line(self, line: str):
42
+ if "Solving for" in line or "Courant" in line or "Time = " in line:
43
+ console.print(f" [dim]{line}[/dim]")
44
+
45
+ def print_diff(self, diff: str):
46
+ console.print()
47
+ syntax = Syntax(diff, "diff", theme="monokai", line_numbers=False)
48
+ console.print(Panel(syntax, title="[bold]Proposed changes[/bold]", border_style="yellow"))
49
+
50
+ def confirm(self, prompt: str) -> bool:
51
+ try:
52
+ answer = console.input(f"\n {prompt} [Y/n] ").strip().lower()
53
+ return answer in ("", "y", "yes")
54
+ except (KeyboardInterrupt, EOFError):
55
+ console.print("\n[dim](cancelled)[/dim]")
56
+ return False
57
+
58
+ def print_error(self, msg: str):
59
+ console.print(f"\n[red]Error: {msg}[/red]")
60
+
61
+ def print_stuck(self, attempts: int):
62
+ console.print(Panel(
63
+ f"[yellow]I have made {attempts} fix attempts without success.[/yellow]\n"
64
+ "The remaining issue likely requires manual inspection.\n"
65
+ "Run [cyan]checkMesh[/cyan] if you suspect a mesh quality problem.",
66
+ title="[bold yellow]Stuck[/bold yellow]",
67
+ border_style="yellow"
68
+ ))
69
+
70
+ def print_goodbye(self):
71
+ console.print("\n[dim]Session ended.[/dim]")
cfdpilot/main.py ADDED
@@ -0,0 +1,47 @@
1
+ import os
2
+ import typer
3
+ from rich.console import Console
4
+ from cfdpilot.auth import device_login, save_token, load_token
5
+ from cfdpilot.case_detector import detect_case
6
+ from cfdpilot.display import Display
7
+ from cfdpilot.agent import run_agent_loop
8
+
9
+ app = typer.Typer(help="CFDPilot Agent — AI engineer for your OpenFOAM cases")
10
+ console = Console()
11
+
12
+
13
+ @app.command("login")
14
+ def login():
15
+ """Authenticate with your CFDpilot account."""
16
+ console.print("\n[bold]CFDPilot Agent[/bold] — Login\n")
17
+ try:
18
+ token = device_login()
19
+ save_token(token)
20
+ console.print("[green]Logged in successfully.[/green]")
21
+ except (TimeoutError, ConnectionError) as e:
22
+ console.print(f"[red]{e}[/red]")
23
+ raise typer.Exit(1)
24
+
25
+
26
+ @app.command("setup")
27
+ def setup(token: str = typer.Argument(..., help="Your CFDpilot access token")):
28
+ """Activate your CFDpilot access token."""
29
+ save_token(token)
30
+ console.print("[green]Token saved. You're ready — run: cfdpilot[/green]")
31
+
32
+
33
+ @app.callback(invoke_without_command=True)
34
+ def main(ctx: typer.Context):
35
+ """Run the CFDPilot Agent in the current OpenFOAM case directory."""
36
+ if ctx.invoked_subcommand is not None:
37
+ return
38
+
39
+ token = load_token()
40
+ if not token:
41
+ console.print("[red]Not logged in. Run:[/red] cfdpilot login")
42
+ raise typer.Exit(1)
43
+
44
+ cwd = os.getcwd()
45
+ case_info = detect_case(cwd)
46
+ display = Display(case_info)
47
+ run_agent_loop(token=token, cwd=cwd, display=display, case_info=case_info)
File without changes
@@ -0,0 +1,45 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+
5
+ def _safe_path(path: str, cwd: str) -> Path | None:
6
+ base = Path(cwd).resolve()
7
+ target = (base / path).resolve()
8
+ if target != base and not str(target).startswith(str(base) + os.sep):
9
+ return None
10
+ return target
11
+
12
+
13
+ MAX_FILE_CHARS = 50_000 # ~12k tokens — prevents log files from blowing up the context
14
+
15
+
16
+ def read_file(path: str, cwd: str) -> dict:
17
+ safe = _safe_path(path, cwd)
18
+ if safe is None:
19
+ return {"content": None, "error": "Access denied: path outside case directory"}
20
+ if not safe.exists():
21
+ return {"content": None, "error": f"File not found: {path}"}
22
+ try:
23
+ content = safe.read_text(errors="replace")
24
+ truncated = len(content) > MAX_FILE_CHARS
25
+ if truncated:
26
+ content = content[:MAX_FILE_CHARS] + f"\n\n[TRUNCATED — file is {len(content)} chars, showing first {MAX_FILE_CHARS}. Use parse_log for log files.]"
27
+ return {"content": content, "error": None, "size": len(content), "truncated": truncated}
28
+ except Exception as e:
29
+ return {"content": None, "error": str(e)}
30
+
31
+
32
+ def list_dir(path: str, cwd: str) -> dict:
33
+ safe = _safe_path(path, cwd)
34
+ if safe is None:
35
+ return {"entries": [], "error": "Access denied"}
36
+ if not safe.exists():
37
+ return {"entries": [], "error": f"Directory not found: {path}"}
38
+ entries = []
39
+ for item in sorted(safe.iterdir()):
40
+ entries.append({
41
+ "name": item.name,
42
+ "type": "dir" if item.is_dir() else "file",
43
+ "size": item.stat().st_size if item.is_file() else None
44
+ })
45
+ return {"entries": [e["name"] for e in entries], "details": entries, "error": None}
@@ -0,0 +1,94 @@
1
+ import re
2
+ from pathlib import Path
3
+
4
+ SOLVER_PATTERN = re.compile(r"^Exec\s+:\s+(\w+)", re.MULTILINE)
5
+ VERSION_PATTERN = re.compile(r"Version:\s+([\w.]+)")
6
+ BUILD_PATTERN = re.compile(r"^Build\s+:\s+([\w.]+)", re.MULTILINE)
7
+ CO_PATTERN = re.compile(r"Courant Number mean:\s+[\d.]+\s+max:\s+([\d.]+)")
8
+ RESIDUAL_PATTERN = re.compile(
9
+ r"Solving for (\w+),\s+Initial residual = ([\d.eE+-]+),\s+Final residual = ([\d.eE+-]+)"
10
+ )
11
+ CONVERGED_PATTERN = re.compile(r"(SIMPLE solution converged|End)", re.IGNORECASE)
12
+ FATAL_PATTERN = re.compile(r"FOAM FATAL|Floating point exception|divergence detected", re.IGNORECASE)
13
+
14
+
15
+ _UTILITY_LOGS = {
16
+ "blockMesh", "checkMesh", "snappyHexMesh", "decomposePar", "reconstructPar",
17
+ "reconstructParMesh", "surfaceFeatures", "surfaceFeatureExtract", "renumberMesh",
18
+ "setFields", "extrudeMesh", "topoSet", "createPatch", "transformPoints",
19
+ "mergeMeshes", "mirrorMesh", "splitMeshRegions", "mapFields", "changeDictionary",
20
+ "createBaffles", "refineMesh", "moveDynamicMesh", "postProcess", "foamToVTK",
21
+ }
22
+
23
+
24
+ def _find_log(cwd: str) -> Path | None:
25
+ """Return the solver log, not a meshing/utility log.
26
+
27
+ Auto-detect previously grabbed the first log.* alphabetically (e.g. log.blockMesh),
28
+ a meshing step rather than the solver run. Exclude utility logs and prefer the most
29
+ recently modified remaining log (the solver runs after meshing/setup).
30
+ """
31
+ cwd_path = Path(cwd)
32
+ logs = [f for f in cwd_path.glob("log.*") if f.is_file()]
33
+ if not logs:
34
+ return None
35
+ solver_logs = [f for f in logs if f.name[len("log."):] not in _UTILITY_LOGS]
36
+ pool = solver_logs or logs
37
+ return max(pool, key=lambda f: f.stat().st_mtime)
38
+
39
+
40
+ def parse_log(log_path: str | None, cwd: str = ".") -> dict:
41
+ if log_path is None:
42
+ found = _find_log(cwd)
43
+ if not found:
44
+ return {"error": "No log file found. Run your solver first."}
45
+ log_path = str(found)
46
+
47
+ path = Path(log_path)
48
+ if not path.exists():
49
+ return {"error": f"Log file not found: {log_path}"}
50
+
51
+ text = path.read_text(errors="replace")
52
+ lines = text.splitlines()
53
+
54
+ solver_match = SOLVER_PATTERN.search(text)
55
+ solver = solver_match.group(1) if solver_match else "unknown"
56
+
57
+ ver_match = VERSION_PATTERN.search(text) or BUILD_PATTERN.search(text)
58
+ of_version = ver_match.group(1) if ver_match else "unknown"
59
+
60
+ residuals: dict[str, list[float]] = {}
61
+ for match in RESIDUAL_PATTERN.finditer(text):
62
+ field, init_res = match.group(1), float(match.group(2))
63
+ residuals.setdefault(field, []).append(init_res)
64
+
65
+ co_numbers = [float(m.group(1)) for m in CO_PATTERN.finditer(text)]
66
+ co_max = max(co_numbers) if co_numbers else None
67
+
68
+ fatal_match = FATAL_PATTERN.search(text)
69
+ diverged = fatal_match is not None
70
+ divergence_at = None
71
+ if diverged:
72
+ for i, line in enumerate(lines):
73
+ if FATAL_PATTERN.search(line):
74
+ divergence_at = i + 1
75
+ break
76
+
77
+ converged = bool(CONVERGED_PATTERN.search(text)) and not diverged
78
+
79
+ warnings = [l.strip() for l in lines if "WARNING" in l.upper() or "WARN:" in l]
80
+
81
+ return {
82
+ "solver": solver,
83
+ "of_version": of_version,
84
+ "log_path": str(log_path),
85
+ "iterations": len(residuals.get(next(iter(residuals), "p"), [])),
86
+ "residuals": {k: v[-5:] for k, v in residuals.items()},
87
+ "Co_max": co_max,
88
+ "diverged": diverged,
89
+ "divergence_at": divergence_at,
90
+ "converged": converged,
91
+ "warnings": warnings[:10],
92
+ "errors": [l.strip() for l in lines if "FATAL" in l or "Floating point" in l],
93
+ "error": None
94
+ }
@@ -0,0 +1,42 @@
1
+ import re
2
+ import shutil
3
+ import difflib
4
+ from pathlib import Path
5
+
6
+
7
+ def generate_diff(file_path: str, proposed_content: str) -> str:
8
+ path = Path(file_path)
9
+ if not path.exists():
10
+ return f"(new file)\n{proposed_content}"
11
+ current = path.read_text().splitlines(keepends=True)
12
+ proposed = proposed_content.splitlines(keepends=True)
13
+ diff = difflib.unified_diff(
14
+ current, proposed,
15
+ fromfile=f"{path.name} (current)",
16
+ tofile=f"{path.name} (proposed)",
17
+ lineterm=""
18
+ )
19
+ return "\n".join(diff)
20
+
21
+
22
+ def patch_dict(file_path: str, changes: dict) -> dict:
23
+ path = Path(file_path)
24
+ if not path.exists():
25
+ raise FileNotFoundError(f"File not found: {file_path}")
26
+
27
+ content = path.read_text()
28
+ backup = Path(str(path) + ".bak")
29
+ shutil.copy(path, backup)
30
+
31
+ for key, new_value in changes.items():
32
+ simple_key = key.split(".")[-1]
33
+ pattern = rf"(\b{re.escape(simple_key)}\s+)[^\n;]+;"
34
+ replacement = rf"\g<1>{new_value};"
35
+ new_content = re.sub(pattern, replacement, content, count=1)
36
+ if new_content == content:
37
+ pattern = rf"({re.escape(simple_key)}\s+)[^\n;]+;"
38
+ new_content = re.sub(pattern, replacement, content, count=1)
39
+ content = new_content
40
+
41
+ path.write_text(content)
42
+ return {"success": True, "backup": str(backup)}
@@ -0,0 +1,159 @@
1
+ import os
2
+ import re
3
+ import subprocess
4
+ import shlex
5
+ from pathlib import Path
6
+ from typing import Callable
7
+
8
+ DIVERGENCE_SIGNALS = [
9
+ "FOAM FATAL",
10
+ "Floating point exception",
11
+ "divergence detected",
12
+ "solution singularity",
13
+ ]
14
+ CONVERGENCE_SIGNALS = [
15
+ "SIMPLE solution converged",
16
+ "solution converged",
17
+ ]
18
+
19
+ _HOME = str(Path.home())
20
+ _DOCKER_WORKDIR = "/home/ofuser/workingDir"
21
+ # Configurable — was hardcoded "of_v11", which broke for any other container name.
22
+ _DOCKER_BASHRC = os.environ.get("CFDPILOT_OF_BASHRC", "/opt/openfoam11/etc/bashrc")
23
+ _CONTAINER_CACHE = None
24
+
25
+
26
+ def _docker_container() -> str:
27
+ """Resolve the OpenFOAM Docker container name.
28
+
29
+ Order: CFDPILOT_DOCKER_CONTAINER env var → first running OpenFOAM-ish
30
+ container (via `docker ps`) → 'of_v11' fallback. Result is cached.
31
+ """
32
+ global _CONTAINER_CACHE
33
+ if _CONTAINER_CACHE:
34
+ return _CONTAINER_CACHE
35
+ name = os.environ.get("CFDPILOT_DOCKER_CONTAINER")
36
+ if not name:
37
+ try:
38
+ out = subprocess.run(
39
+ ["docker", "ps", "--format", "{{.Names}}"],
40
+ capture_output=True, text=True, timeout=10
41
+ ).stdout
42
+ name = next((n for n in out.split() if re.search(r"foam|of[_-]?v?\d", n, re.I)), None)
43
+ except Exception:
44
+ name = None
45
+ _CONTAINER_CACHE = name or "of_v11"
46
+ return _CONTAINER_CACHE
47
+
48
+
49
+ def _to_docker_path(mac_path: str) -> str:
50
+ abs_path = str(Path(mac_path).resolve())
51
+ if abs_path.startswith(_HOME):
52
+ return _DOCKER_WORKDIR + abs_path[len(_HOME):]
53
+ return abs_path
54
+
55
+
56
+ def _wrap_docker(cmd: str, cwd: str) -> list:
57
+ """Return a docker exec command list that runs cmd inside the OF container with OF sourced."""
58
+ docker_cwd = _to_docker_path(cwd)
59
+ # Source the configured bashrc if present, else glob common Foundation/ESI locations.
60
+ source = (
61
+ f'if [ -f "{_DOCKER_BASHRC}" ]; then source "{_DOCKER_BASHRC}"; '
62
+ f'else source $(ls /opt/openfoam*/etc/bashrc /usr/lib/openfoam/openfoam*/etc/bashrc 2>/dev/null | head -1); fi'
63
+ )
64
+ inner = f"{source} && cd {shlex.quote(docker_cwd)} && {cmd}"
65
+ return ["docker", "exec", _docker_container(), "bash", "-c", inner]
66
+
67
+
68
+ def run_command(cmd: str, cwd: str = ".", timeout: int = 300) -> dict:
69
+ try:
70
+ result = subprocess.run(
71
+ cmd, shell=True, cwd=cwd,
72
+ capture_output=True, text=True, timeout=timeout
73
+ )
74
+ if result.returncode == 127:
75
+ # Command not found in PATH — retry inside Docker container
76
+ result = subprocess.run(
77
+ _wrap_docker(cmd, cwd), cwd=cwd,
78
+ capture_output=True, text=True, timeout=timeout
79
+ )
80
+ return {
81
+ "stdout": result.stdout,
82
+ "stderr": result.stderr,
83
+ "exit_code": result.returncode,
84
+ "error": None
85
+ }
86
+ except subprocess.TimeoutExpired:
87
+ return {"stdout": "", "stderr": "", "exit_code": -1, "error": "timeout"}
88
+ except Exception as e:
89
+ return {"stdout": "", "stderr": "", "exit_code": -1, "error": str(e)}
90
+
91
+
92
+ def run_solver(
93
+ solver: str,
94
+ cwd: str = ".",
95
+ max_iterations: int | None = None,
96
+ on_line: Callable[[str], None] | None = None,
97
+ ) -> dict:
98
+ try:
99
+ proc = subprocess.Popen(
100
+ [solver], cwd=cwd,
101
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
102
+ text=True, bufsize=1
103
+ )
104
+ except FileNotFoundError:
105
+ # Solver not in PATH — run inside Docker container
106
+ try:
107
+ proc = subprocess.Popen(
108
+ _wrap_docker(solver, cwd), cwd=cwd,
109
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
110
+ text=True, bufsize=1
111
+ )
112
+ except Exception as e:
113
+ return {
114
+ "diverged": False, "converged": False, "iterations": 0, "log_lines": [],
115
+ "exit_code": -1,
116
+ "error": f"Could not start solver '{solver}'. Is your OpenFOAM Docker container running? Start it (e.g. `docker start {_docker_container()}`), or set CFDPILOT_DOCKER_CONTAINER / CFDPILOT_OF_BASHRC. Error: {e}"
117
+ }
118
+
119
+ lines = []
120
+ diverged = False
121
+ converged = False
122
+ iteration = 0
123
+
124
+ try:
125
+ for line in proc.stdout:
126
+ line = line.rstrip()
127
+ lines.append(line)
128
+ if on_line:
129
+ on_line(line)
130
+
131
+ if any(sig in line for sig in DIVERGENCE_SIGNALS):
132
+ diverged = True
133
+ proc.terminate()
134
+ break
135
+ if any(sig in line for sig in CONVERGENCE_SIGNALS):
136
+ converged = True
137
+ proc.terminate()
138
+ break
139
+ if "Time = " in line:
140
+ iteration += 1
141
+ if max_iterations and iteration >= max_iterations:
142
+ proc.terminate()
143
+ break
144
+
145
+ proc.wait(timeout=5)
146
+ except Exception:
147
+ try:
148
+ proc.kill()
149
+ proc.wait(timeout=2)
150
+ except Exception:
151
+ pass
152
+
153
+ return {
154
+ "diverged": diverged,
155
+ "converged": converged,
156
+ "iterations": iteration,
157
+ "log_lines": lines[-50:],
158
+ "exit_code": proc.returncode if proc.returncode is not None else -1,
159
+ }
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: cfdpilot
3
+ Version: 0.1.1
4
+ Summary: AI agent for OpenFOAM — diagnose, fix, and relaunch cases from your terminal
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: httpx>=0.27
7
+ Requires-Dist: python-dotenv>=1.0
8
+ Requires-Dist: rich>=13
9
+ Requires-Dist: typer>=0.12
10
+ Provides-Extra: dev
11
+ Requires-Dist: httpx; extra == 'dev'
12
+ Requires-Dist: pytest-asyncio; extra == 'dev'
13
+ Requires-Dist: pytest>=8; extra == 'dev'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # CFDpilot
17
+
18
+ **An AI agent for OpenFOAM — in your terminal.**
19
+
20
+ CFDpilot reads your OpenFOAM case directly from disk, diagnoses why it diverges or
21
+ gives wrong physics, proposes the exact fix as a colored diff, and — once you confirm —
22
+ patches the files and reruns the solver. No ZIP upload, no browser. It runs where you
23
+ already work: your laptop, your workstation, or an HPC node over SSH.
24
+
25
+ ```bash
26
+ pip install cfdpilot
27
+ cfdpilot login # authenticate once
28
+ cd path/to/your/case && cfdpilot
29
+ ```
30
+
31
+ Then just ask, in plain English:
32
+
33
+ > *"Why won't my rhoSimpleFoam case converge?"*
34
+
35
+ The agent maps your case, reads `fvSchemes`, `fvSolution`, your boundary conditions and
36
+ solver log, reasons from the actual numbers (Courant number, residuals, turbulence wall
37
+ treatment, scheme/switch consistency), and reports the bugs ranked by impact — with the
38
+ exact fix for each. Nothing is changed without showing you a diff first, and every patch
39
+ makes a `.bak` backup.
40
+
41
+ ## What it checks
42
+
43
+ Boundary-condition consistency, turbulence wall treatment (low-Re vs high-Re), Courant
44
+ number and time-step control, relaxation factors, discretisation schemes, solver-mode
45
+ switches (e.g. `transonic`), and solver/turbulence-model compatibility — across
46
+ incompressible, compressible, multiphase (VoF) and heat-transfer solvers.
47
+
48
+ ## Requirements
49
+
50
+ - Python 3.10+
51
+ - An OpenFOAM case directory (Foundation or ESI). OpenFOAM itself can run natively or in
52
+ Docker; the agent will use it to run the solver when you ask.
53
+
54
+ ## Access
55
+
56
+ CFDpilot is in **free early access** for the first engineers. Run `cfdpilot login` to get
57
+ started, or request access at [cfdpilot.com](https://cfdpilot.com).
@@ -0,0 +1,16 @@
1
+ cfdpilot/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ cfdpilot/agent.py,sha256=1i48rTot82I5BOMsZ-MruUSubJ4LDY01yB6ycbmpjgA,11243
3
+ cfdpilot/auth.py,sha256=B2V1ZFKcYls39EXG0z40-l7yVl9liLs7BagHGNtRKOk,1748
4
+ cfdpilot/case_detector.py,sha256=J6ME-FFwmwNvI51iqnYbSsBl7O7ibZrVZwf7DSqGFr4,3695
5
+ cfdpilot/client.py,sha256=mPS7BZWx2dBF6WL5ILTmCa3CXx749FX1lPcfQJPepG8,1994
6
+ cfdpilot/display.py,sha256=r4XbmSq5phQ30_Lq1lz9GvQ4kzPzg6Fe7dT6POs46NE,2522
7
+ cfdpilot/main.py,sha256=uny0TSRk5Wn9Go1F0PTLWHBm7sFSQaeMmPnZZ-5Cj68,1516
8
+ cfdpilot/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ cfdpilot/tools/filesystem.py,sha256=5JAELASlu_KLXC3MnAvx8N-UjtcqxGVlW1IYAXK_jgE,1703
10
+ cfdpilot/tools/log_parser.py,sha256=VEESE6AMsl5oF5gQJeDak6o3_ZUpFXnF3hxnq2RVSzc,3669
11
+ cfdpilot/tools/patcher.py,sha256=CM06A-CGjp2sZr4AiAGExJee3vA2smHUa-hzcJaq7Xw,1361
12
+ cfdpilot/tools/runner.py,sha256=hhsMd8NcdgSEKugpKvfh1fBrej8mlktIveIup-G82wc,5291
13
+ cfdpilot-0.1.1.dist-info/METADATA,sha256=pMGLjtVGwhwkZvbOul6JQoKzS61i6q_55vAwpEGJMvU,2141
14
+ cfdpilot-0.1.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
15
+ cfdpilot-0.1.1.dist-info/entry_points.txt,sha256=kWaVnnh6PvfLHuTfpuFLA4y1XIlCj_dWE-8JhAPtmgA,47
16
+ cfdpilot-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cfdpilot = cfdpilot.main:app