stackfix 0.2.0

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.
@@ -0,0 +1,298 @@
1
+ import json
2
+ import os
3
+ import shlex
4
+ import sys
5
+ import requests
6
+ from typing import Dict, Any, Optional, Tuple
7
+
8
+ from .util import env_required
9
+
10
+ SYSTEM_PROMPT = (
11
+ "You are StackFix, an agent that proposes minimal safe patches to fix a failing command. "
12
+ "Return ONLY a single JSON object in the assistant message content, with keys: "
13
+ "summary (string), confidence (0-1 number), patch_unified_diff (string), "
14
+ "rerun_command (array of strings). "
15
+ "No markdown, no backticks, no extra text. "
16
+ "The patch must be a valid git unified diff that starts with: "
17
+ "diff --git a/<path> b/<path>, includes --- a/<path>, +++ b/<path>, and hunk headers "
18
+ "with ranges like @@ -1,2 +1,8 @@ (no bare @@ lines)."
19
+ )
20
+
21
+ PROMPT_MODE_SYSTEM_PROMPT = (
22
+ "You are StackFix, a helpful AI coding assistant. "
23
+ "Answer the user's question directly and concisely. "
24
+ "If asked about code, provide clear explanations. "
25
+ "If asked to modify code, explain what changes would be needed. "
26
+ "Keep responses focused and practical."
27
+ )
28
+
29
+ STRICT_DIFF_PROMPT = (
30
+ "Your diff was invalid. Return a valid git unified diff with proper @@ ranges. "
31
+ "Return ONLY a single JSON object in the assistant message content with keys: "
32
+ "summary, confidence, patch_unified_diff, rerun_command. "
33
+ "patch_unified_diff MUST be a valid git unified diff only (no Begin Patch markers), "
34
+ "starting with: diff --git a/<path> b/<path>, including ---/+++ lines, and hunk headers "
35
+ "with ranges like @@ -1,2 +1,8 @@. Do NOT use bare @@. "
36
+ "Example hunk header: @@ -1,2 +1,2 @@. No extra text."
37
+ )
38
+
39
+ _ENDPOINT_LOGGED = False
40
+
41
+
42
+ def _log_endpoint_once(url: str) -> None:
43
+ global _ENDPOINT_LOGGED
44
+ if _ENDPOINT_LOGGED:
45
+ return
46
+ _ENDPOINT_LOGGED = True
47
+ print(f"[stackfix] Using model endpoint: {url}", file=sys.stderr)
48
+
49
+
50
+ def _is_debug() -> bool:
51
+ return os.environ.get("STACKFIX_DEBUG") == "1"
52
+
53
+
54
+ def _redact_secrets(text: str) -> str:
55
+ api_key = os.environ.get("MODEL_API_KEY")
56
+ if api_key:
57
+ text = text.replace(api_key, "[REDACTED]")
58
+ for token_key in ["api_key", "apikey", "token", "authorization", "bearer"]:
59
+ text = text.replace(token_key, f"{token_key[:2]}***")
60
+ return text
61
+
62
+
63
+ def _debug_log(msg: str) -> None:
64
+ if _is_debug():
65
+ print(f"[stackfix][debug] {msg}", file=sys.stderr)
66
+
67
+
68
+ def _model_request_payload(context: Dict[str, Any], system_prompt: str = SYSTEM_PROMPT) -> Dict[str, Any]:
69
+ max_tokens = int(os.environ.get("MODEL_MAX_TOKENS", "2000"))
70
+
71
+ # For prompt mode, use plain text format and simpler prompt
72
+ is_prompt_mode = context.get("mode") == "prompt"
73
+ if is_prompt_mode and system_prompt == SYSTEM_PROMPT:
74
+ system_prompt = PROMPT_MODE_SYSTEM_PROMPT
75
+
76
+ # Build user message - for prompt mode, just send the prompt text
77
+ if is_prompt_mode:
78
+ user_content = context.get("prompt", "")
79
+ if context.get("agent_instructions"):
80
+ user_content = f"Project context:\n{context['agent_instructions']}\n\nUser question: {user_content}"
81
+ else:
82
+ user_content = json.dumps(context)
83
+
84
+ payload = {
85
+ "model": env_required("MODEL_NAME"),
86
+ "temperature": 0.2,
87
+ "max_tokens": max_tokens,
88
+ "messages": [
89
+ {"role": "system", "content": system_prompt},
90
+ {"role": "user", "content": user_content},
91
+ ],
92
+ }
93
+ # Only request JSON format for non-prompt mode
94
+ if not is_prompt_mode and os.environ.get("STACKFIX_NO_RESPONSE_FORMAT") != "1":
95
+ payload["response_format"] = {"type": "json_object"}
96
+ return payload
97
+
98
+
99
+ def _call_direct(context: Dict[str, Any], system_prompt: str = SYSTEM_PROMPT) -> Dict[str, Any]:
100
+ base_url = env_required("MODEL_BASE_URL").rstrip("/")
101
+ api_key = env_required("MODEL_API_KEY")
102
+ if base_url.endswith("/v1"):
103
+ url = f"{base_url}/chat/completions"
104
+ else:
105
+ url = f"{base_url}/v1/chat/completions"
106
+ _log_endpoint_once(url)
107
+ payload = _model_request_payload(context, system_prompt=system_prompt)
108
+ headers = {
109
+ "Authorization": f"Bearer {api_key}",
110
+ "Content-Type": "application/json",
111
+ }
112
+ resp = requests.post(url, headers=headers, json=payload, timeout=60)
113
+ _debug_log(f"HTTP status: {resp.status_code}")
114
+ resp.raise_for_status()
115
+ raw_text = resp.text
116
+ _debug_log(f"Raw response (first 500 chars): { _redact_secrets(raw_text[:500]) }")
117
+ data = resp.json()
118
+ content = _extract_content(data)
119
+ return _parse_agent_response(content)
120
+
121
+
122
+ def _call_modal(endpoint: str, context: Dict[str, Any], system_prompt: str = SYSTEM_PROMPT) -> Dict[str, Any]:
123
+ payload = _model_request_payload(context, system_prompt=system_prompt)
124
+ resp = requests.post(endpoint, json=payload, timeout=60)
125
+ _debug_log(f"HTTP status: {resp.status_code}")
126
+ resp.raise_for_status()
127
+ raw_text = resp.text
128
+ _debug_log(f"Raw response (first 500 chars): { _redact_secrets(raw_text[:500]) }")
129
+ data = resp.json()
130
+ content = data.get("content") or data.get("response") or data
131
+ if isinstance(content, dict):
132
+ return _validate_agent_json(content)
133
+ return _parse_agent_response(content)
134
+
135
+
136
+ def _parse_agent_response(content: str) -> Dict[str, Any]:
137
+ parsed: Optional[Dict[str, Any]] = None
138
+ warning: Optional[str] = None
139
+ if content is None:
140
+ return _fallback_response("", "Agent returned empty content")
141
+ try:
142
+ parsed = json.loads(content)
143
+ except Exception:
144
+ warning = "Agent response was not valid JSON; falling back to raw content"
145
+ return _fallback_response(content, warning)
146
+ return _validate_agent_json(parsed, raw_content=content)
147
+
148
+
149
+ def _extract_content(data: Dict[str, Any]) -> str:
150
+ def _get(obj, key, default=None):
151
+ if isinstance(obj, dict):
152
+ return obj.get(key, default)
153
+ return getattr(obj, key, default)
154
+
155
+ choices = _get(data, "choices")
156
+ if isinstance(choices, list) and choices:
157
+ choice = choices[0]
158
+ message = _get(choice, "message")
159
+ if message is not None:
160
+ content = _get(message, "content")
161
+ if isinstance(content, list):
162
+ parts = []
163
+ for part in content:
164
+ if isinstance(part, dict) and "text" in part:
165
+ parts.append(part["text"])
166
+ if parts:
167
+ _debug_log("Parsed content from message.content list")
168
+ return "".join(parts)
169
+ if content is not None and content.strip():
170
+ _debug_log("Parsed content from message.content")
171
+ return content
172
+ # Check for reasoning_content (used by some models like Nebius)
173
+ reasoning_content = _get(message, "reasoning_content")
174
+ if reasoning_content is not None and str(reasoning_content).strip():
175
+ _debug_log("Parsed content from message.reasoning_content")
176
+ return str(reasoning_content)
177
+ tool_calls = _get(message, "tool_calls")
178
+ if isinstance(tool_calls, list) and tool_calls:
179
+ fn = _get(tool_calls[0], "function", {})
180
+ args = _get(fn, "arguments")
181
+ if args:
182
+ _debug_log("Parsed content from message.tool_calls.function.arguments")
183
+ return args
184
+ text = _get(choice, "text")
185
+ if text is not None:
186
+ _debug_log("Parsed content from choice.text")
187
+ return text
188
+ keys = list(data.keys()) if isinstance(data, dict) else dir(data)
189
+ raise RuntimeError(f"Agent returned no content to parse; top-level keys: {keys}")
190
+
191
+
192
+ def _normalize_rerun_command(value: Any) -> list:
193
+ if value is None:
194
+ return []
195
+ if isinstance(value, list):
196
+ return [str(v) for v in value if v is not None]
197
+ if isinstance(value, str):
198
+ try:
199
+ return shlex.split(value)
200
+ except Exception:
201
+ return [value]
202
+ return [str(value)]
203
+
204
+
205
+ def _coerce_confidence(value: Any) -> Optional[float]:
206
+ if value is None:
207
+ return None
208
+ if isinstance(value, (int, float)):
209
+ return float(value)
210
+ if isinstance(value, str):
211
+ try:
212
+ return float(value)
213
+ except Exception:
214
+ return None
215
+ return None
216
+
217
+
218
+ def _fallback_response(content: str, warning: Optional[str]) -> Dict[str, Any]:
219
+ return {
220
+ "summary": content.strip(),
221
+ "confidence": None,
222
+ "patch_unified_diff": "",
223
+ "rerun_command": [],
224
+ "_raw_content": content,
225
+ "_warning": warning,
226
+ }
227
+
228
+
229
+ def _validate_agent_json(parsed: Dict[str, Any], raw_content: Optional[str] = None) -> Dict[str, Any]:
230
+ if not isinstance(parsed, dict):
231
+ return _fallback_response(raw_content or str(parsed), "Agent JSON was not an object")
232
+
233
+ summary = parsed.get("summary")
234
+ if summary is None:
235
+ summary = ""
236
+ summary = str(summary)
237
+
238
+ patch = parsed.get("patch_unified_diff") or ""
239
+ if patch is None:
240
+ patch = ""
241
+ patch = str(patch)
242
+
243
+ rerun = _normalize_rerun_command(parsed.get("rerun_command"))
244
+ confidence = _coerce_confidence(parsed.get("confidence"))
245
+
246
+ return {
247
+ "summary": summary,
248
+ "confidence": confidence,
249
+ "patch_unified_diff": patch,
250
+ "rerun_command": rerun,
251
+ "_raw_content": raw_content,
252
+ "_warning": None,
253
+ }
254
+
255
+
256
+ def _is_valid_unified_diff(diff_text: str) -> bool:
257
+ if not isinstance(diff_text, str):
258
+ return False
259
+ if "diff --git " not in diff_text or "--- " not in diff_text or "+++ " not in diff_text:
260
+ return False
261
+ lines = diff_text.splitlines()
262
+ has_hunk = False
263
+ for line in lines:
264
+ if line.startswith("@@"):
265
+ if not line.startswith("@@ -"):
266
+ return False
267
+ if not _is_valid_hunk_header(line):
268
+ return False
269
+ has_hunk = True
270
+ return has_hunk
271
+
272
+
273
+ def _is_valid_hunk_header(line: str) -> bool:
274
+ import re
275
+ return re.match(r"^@@ -\d+(,\d+)? \+\d+(,\d+)? @@", line) is not None
276
+
277
+
278
+ def call_agent(context: Dict[str, Any]) -> Dict[str, Any]:
279
+ endpoint = os.environ.get("STACKFIX_ENDPOINT")
280
+ if endpoint:
281
+ result = _call_modal(endpoint, context)
282
+ else:
283
+ result = _call_direct(context)
284
+
285
+ patch = result.get("patch_unified_diff", "")
286
+ if _is_valid_unified_diff(patch):
287
+ return result
288
+
289
+ _debug_log("Invalid patch format; retrying once with strict diff prompt")
290
+ if endpoint:
291
+ result = _call_modal(endpoint, context, system_prompt=STRICT_DIFF_PROMPT)
292
+ else:
293
+ result = _call_direct(context, system_prompt=STRICT_DIFF_PROMPT)
294
+ patch = result.get("patch_unified_diff", "")
295
+ if _is_valid_unified_diff(patch):
296
+ return result
297
+ _debug_log("Agent returned invalid unified diff after retry; passing to fallback applier")
298
+ return result
@@ -0,0 +1,29 @@
1
+ import os
2
+ from typing import Optional
3
+
4
+ MAX_AGENT_BYTES = 12000
5
+
6
+
7
+ def find_agents_file(cwd: str) -> Optional[str]:
8
+ current = os.path.abspath(cwd)
9
+ root = os.path.abspath(os.sep)
10
+ while True:
11
+ candidate = os.path.join(current, "AGENTS.md")
12
+ if os.path.isfile(candidate):
13
+ return candidate
14
+ if current == root:
15
+ return None
16
+ current = os.path.dirname(current)
17
+
18
+
19
+ def load_agents_instructions(cwd: str) -> Optional[str]:
20
+ path = find_agents_file(cwd)
21
+ if not path:
22
+ return None
23
+ try:
24
+ if os.path.getsize(path) > MAX_AGENT_BYTES:
25
+ return None
26
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
27
+ return f.read()
28
+ except Exception:
29
+ return None
@@ -0,0 +1,169 @@
1
+ import argparse
2
+ import json
3
+ import os
4
+ import sys
5
+ from typing import List
6
+
7
+ from .agent import call_agent
8
+ from .context import collect_context
9
+ from .history import write_history, read_last
10
+ from .patching import apply_patch
11
+ from .util import run_command_stream
12
+ from .agents import load_agents_instructions
13
+ from .tui import run_tui
14
+
15
+
16
+ def _print_last(cwd: str) -> int:
17
+ last = read_last(cwd)
18
+ if not last:
19
+ print("No history found.")
20
+ return 1
21
+ print("Last run summary:")
22
+ print(last.get("summary", ""))
23
+ print("\nPatch:")
24
+ print(last.get("patch", ""))
25
+ return 0
26
+
27
+
28
+ def _normalize_command(cmd: List[str]) -> List[str]:
29
+ if cmd and cmd[0] == "--":
30
+ return cmd[1:]
31
+ return cmd
32
+
33
+
34
+ def main() -> None:
35
+ parser = argparse.ArgumentParser(description="StackFix command wrapper")
36
+ parser.add_argument("--last", action="store_true", help="Show last run summary and patch")
37
+ parser.add_argument("--prompt", type=str, help="Run a single prompt non-interactively")
38
+ parser.add_argument("command", nargs=argparse.REMAINDER, help="Command to run after --")
39
+ args = parser.parse_args()
40
+
41
+ cwd = os.getcwd()
42
+
43
+ if args.last:
44
+ sys.exit(_print_last(cwd))
45
+
46
+ if args.prompt:
47
+ context = {
48
+ "mode": "prompt",
49
+ "prompt": args.prompt,
50
+ "cwd": cwd,
51
+ }
52
+ agents = load_agents_instructions(cwd)
53
+ if agents:
54
+ context["agent_instructions"] = agents
55
+ try:
56
+ agent_result = call_agent(context)
57
+ except Exception as exc:
58
+ print(f"Agent call failed: {exc}", file=sys.stderr)
59
+ sys.exit(1)
60
+ summary = agent_result.get("summary", "")
61
+ warning = agent_result.get("_warning")
62
+ if warning:
63
+ print(f"Warning: {warning}", file=sys.stderr)
64
+ print(summary)
65
+ record = {
66
+ "command": None,
67
+ "exit_code": 0,
68
+ "summary": summary,
69
+ "patch": agent_result.get("patch_unified_diff", ""),
70
+ "rerun_exit_code": None,
71
+ }
72
+ write_history(cwd, record)
73
+ sys.exit(0)
74
+
75
+ cmd = _normalize_command(args.command)
76
+ if not cmd:
77
+ run_tui()
78
+ return
79
+
80
+ exit_code, stdout, stderr = run_command_stream(cmd, cwd)
81
+
82
+ if exit_code == 0:
83
+ record = {
84
+ "command": cmd,
85
+ "exit_code": exit_code,
86
+ "summary": "Command succeeded; no patch applied.",
87
+ "patch": "",
88
+ "rerun_exit_code": None,
89
+ }
90
+ write_history(cwd, record)
91
+ sys.exit(exit_code)
92
+
93
+ context = collect_context(cwd, cmd, exit_code, stdout, stderr)
94
+
95
+ try:
96
+ agent_result = call_agent(context)
97
+ except Exception as exc:
98
+ print(f"Agent call failed: {exc}", file=sys.stderr)
99
+ sys.exit(exit_code)
100
+
101
+ warning = agent_result.get("_warning")
102
+ if warning:
103
+ print(f"Warning: {warning}", file=sys.stderr)
104
+
105
+ patch = agent_result.get("patch_unified_diff", "")
106
+ summary = agent_result.get("summary", "")
107
+
108
+ print("\nProposed fix:")
109
+ print(summary)
110
+ print("\nPatch preview:\n")
111
+ print(patch)
112
+
113
+ if not patch.strip():
114
+ record = {
115
+ "command": cmd,
116
+ "exit_code": exit_code,
117
+ "summary": summary,
118
+ "patch": patch,
119
+ "rerun_exit_code": None,
120
+ "applied": False,
121
+ }
122
+ write_history(cwd, record)
123
+ print("No patch provided by agent.")
124
+ sys.exit(exit_code)
125
+
126
+ confirm = input("Apply patch? [y/N]: ").strip().lower()
127
+ if confirm != "y":
128
+ record = {
129
+ "command": cmd,
130
+ "exit_code": exit_code,
131
+ "summary": summary,
132
+ "patch": patch,
133
+ "rerun_exit_code": None,
134
+ "applied": False,
135
+ }
136
+ write_history(cwd, record)
137
+ print("Patch not applied.")
138
+ sys.exit(exit_code)
139
+
140
+ try:
141
+ apply_patch(patch, cwd)
142
+ except Exception as exc:
143
+ print(f"Failed to apply patch: {exc}", file=sys.stderr)
144
+ sys.exit(exit_code)
145
+
146
+ rerun_cmd = agent_result.get("rerun_command") or cmd
147
+ print("\nRerunning command...")
148
+ rerun_exit, rerun_stdout, rerun_stderr = run_command_stream(rerun_cmd, cwd)
149
+
150
+ record = {
151
+ "command": cmd,
152
+ "exit_code": exit_code,
153
+ "summary": summary,
154
+ "patch": patch,
155
+ "rerun_command": rerun_cmd,
156
+ "rerun_exit_code": rerun_exit,
157
+ "rerun_stdout": rerun_stdout,
158
+ "rerun_stderr": rerun_stderr,
159
+ "applied": True,
160
+ }
161
+ write_history(cwd, record)
162
+
163
+ print("\nRun summary:")
164
+ print(json.dumps({"before": exit_code, "after": rerun_exit}, indent=2))
165
+ sys.exit(rerun_exit)
166
+
167
+
168
+ if __name__ == "__main__":
169
+ main()
@@ -0,0 +1,73 @@
1
+ import os
2
+ import subprocess
3
+ from typing import Dict
4
+
5
+ from .util import truncate_text, is_git_repo
6
+ from .safety import is_forbidden_path
7
+ from .agents import load_agents_instructions
8
+
9
+ MAX_STDIO_CHARS = 20000
10
+ MAX_GIT_CHARS = 20000
11
+ MAX_FILE_CHARS = 12000
12
+ MAX_FILE_BYTES = 200000
13
+
14
+ MANIFESTS = [
15
+ "package.json",
16
+ "pnpm-lock.yaml",
17
+ "yarn.lock",
18
+ "pyproject.toml",
19
+ "requirements.txt",
20
+ "poetry.lock",
21
+ ]
22
+
23
+
24
+ def _read_file(path: str) -> str:
25
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
26
+ return f.read()
27
+
28
+
29
+ def collect_context(cwd: str, command: list, exit_code: int, stdout: str, stderr: str) -> Dict:
30
+ ctx = {
31
+ "command": command,
32
+ "cwd": cwd,
33
+ "exit_code": exit_code,
34
+ "stdout": truncate_text(stdout, MAX_STDIO_CHARS),
35
+ "stderr": truncate_text(stderr, MAX_STDIO_CHARS),
36
+ }
37
+
38
+ agents = load_agents_instructions(cwd)
39
+ if agents:
40
+ ctx["agent_instructions"] = truncate_text(agents, MAX_FILE_CHARS)
41
+
42
+ if is_git_repo(cwd):
43
+ try:
44
+ status = subprocess.check_output(["git", "status", "--porcelain"], cwd=cwd, text=True)
45
+ except Exception:
46
+ status = ""
47
+ try:
48
+ diff = subprocess.check_output(["git", "diff"], cwd=cwd, text=True)
49
+ except Exception:
50
+ diff = ""
51
+ ctx["git_status"] = truncate_text(status, MAX_GIT_CHARS)
52
+ ctx["git_diff"] = truncate_text(diff, MAX_GIT_CHARS)
53
+
54
+ files = {}
55
+ for name in MANIFESTS:
56
+ path = os.path.join(cwd, name)
57
+ if not os.path.isfile(path):
58
+ continue
59
+ if is_forbidden_path(path, cwd):
60
+ continue
61
+ size = os.path.getsize(path)
62
+ if size > MAX_FILE_BYTES:
63
+ continue
64
+ try:
65
+ content = _read_file(path)
66
+ except Exception:
67
+ continue
68
+ files[name] = truncate_text(content, MAX_FILE_CHARS)
69
+
70
+ if files:
71
+ ctx["manifests"] = files
72
+
73
+ return ctx
@@ -0,0 +1,32 @@
1
+ import json
2
+ import os
3
+ from datetime import datetime
4
+ from typing import Dict, Any, Optional
5
+
6
+ HISTORY_DIR = ".stackfix/history"
7
+ LAST_FILE = os.path.join(HISTORY_DIR, "last.json")
8
+
9
+
10
+ def ensure_history_dir(cwd: str) -> str:
11
+ path = os.path.join(cwd, HISTORY_DIR)
12
+ os.makedirs(path, exist_ok=True)
13
+ return path
14
+
15
+
16
+ def write_history(cwd: str, record: Dict[str, Any]) -> str:
17
+ history_dir = ensure_history_dir(cwd)
18
+ ts = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
19
+ path = os.path.join(history_dir, f"{ts}.json")
20
+ with open(path, "w", encoding="utf-8") as f:
21
+ json.dump(record, f, indent=2)
22
+ with open(os.path.join(cwd, LAST_FILE), "w", encoding="utf-8") as f:
23
+ json.dump(record, f, indent=2)
24
+ return path
25
+
26
+
27
+ def read_last(cwd: str) -> Optional[Dict[str, Any]]:
28
+ path = os.path.join(cwd, LAST_FILE)
29
+ if not os.path.isfile(path):
30
+ return None
31
+ with open(path, "r", encoding="utf-8") as f:
32
+ return json.load(f)