codexapi 0.5.4__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.
codexapi/foreach.py ADDED
@@ -0,0 +1,228 @@
1
+ """Run a task file over a list of items with resumable progress."""
2
+
3
+ import sys
4
+ import threading
5
+ from concurrent.futures import ThreadPoolExecutor, as_completed
6
+
7
+ from tqdm import tqdm
8
+
9
+ from .taskfile import TaskFile
10
+
11
+ _STATUS_RUNNING = "⏳"
12
+ _STATUS_SUCCESS = "✅"
13
+ _STATUS_FAILED = "❌"
14
+ _STATUS_SET = {_STATUS_RUNNING, _STATUS_SUCCESS, _STATUS_FAILED}
15
+
16
+
17
+ class ForeachResult:
18
+ """Outcome summary for a foreach run."""
19
+
20
+ def __init__(self, succeeded, failed, skipped, results):
21
+ self.succeeded = succeeded
22
+ self.failed = failed
23
+ self.skipped = skipped
24
+ self.results = results
25
+
26
+ def __repr__(self):
27
+ return (
28
+ "ForeachResult("
29
+ f"succeeded={self.succeeded}, "
30
+ f"failed={self.failed}, "
31
+ f"skipped={self.skipped}, "
32
+ f"results={self.results!r}"
33
+ ")"
34
+ )
35
+
36
+
37
+ def foreach(
38
+ list_file,
39
+ task_file,
40
+ n=None,
41
+ cwd=None,
42
+ yolo=True,
43
+ flags=None,
44
+ ):
45
+ """Run a task file over each item in list_file and update the file."""
46
+ lines, ends_with_newline = _read_lines(list_file)
47
+ items, skipped = _collect_items(lines)
48
+
49
+ if not items:
50
+ return ForeachResult(0, 0, skipped, [])
51
+
52
+ max_workers = _max_workers(n, len(items))
53
+ lock = threading.Lock()
54
+ results = []
55
+ counts = {
56
+ "running": 0,
57
+ "success": 0,
58
+ "failed": 0,
59
+ }
60
+
61
+ progress = tqdm(total=len(items))
62
+ try:
63
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
64
+ futures = []
65
+ for index, item in items:
66
+ futures.append(
67
+ executor.submit(
68
+ _run_item,
69
+ index,
70
+ item,
71
+ task_file,
72
+ lines,
73
+ ends_with_newline,
74
+ list_file,
75
+ cwd,
76
+ yolo,
77
+ flags,
78
+ counts,
79
+ results,
80
+ progress,
81
+ lock,
82
+ )
83
+ )
84
+ for future in as_completed(futures):
85
+ future.result()
86
+ finally:
87
+ progress.close()
88
+
89
+ return ForeachResult(
90
+ counts["success"],
91
+ counts["failed"],
92
+ skipped,
93
+ results,
94
+ )
95
+
96
+
97
+ def _max_workers(n, total):
98
+ if n is None:
99
+ return total
100
+ if n < 1:
101
+ raise ValueError("n must be >= 1")
102
+ if n > total:
103
+ return total
104
+ return n
105
+
106
+
107
+ def _read_lines(path):
108
+ with open(path, "r", encoding="utf-8") as handle:
109
+ data = handle.read()
110
+ ends_with_newline = data.endswith("\n")
111
+ return data.splitlines(), ends_with_newline
112
+
113
+
114
+ def _write_lines(path, lines, ends_with_newline):
115
+ text = "\n".join(lines)
116
+ if ends_with_newline:
117
+ text += "\n"
118
+ with open(path, "w", encoding="utf-8") as handle:
119
+ handle.write(text)
120
+
121
+
122
+ def _collect_items(lines):
123
+ items = []
124
+ skipped = 0
125
+ for index, line in enumerate(lines):
126
+ if not line.strip():
127
+ continue
128
+ if _status_marker(line):
129
+ skipped += 1
130
+ continue
131
+ items.append((index, line))
132
+ return items, skipped
133
+
134
+
135
+ def _status_marker(line):
136
+ if not line:
137
+ return None
138
+ marker = line[0]
139
+ if marker in _STATUS_SET:
140
+ return marker
141
+ return None
142
+
143
+
144
+ def _status_text(counts):
145
+ return (
146
+ f"{_STATUS_RUNNING}: {counts['running']}, "
147
+ f"{_STATUS_SUCCESS}: {counts['success']}, "
148
+ f"{_STATUS_FAILED}: {counts['failed']}"
149
+ )
150
+
151
+
152
+ def _single_line(text):
153
+ if not text:
154
+ return ""
155
+ return text.replace("\r", " ").replace("\n", " ")
156
+
157
+
158
+ def _format_turns(used, total):
159
+ used_text = "?" if used is None else str(used)
160
+ total_text = "?" if total is None else str(total)
161
+ return f"[turns: {used_text}/{total_text}]"
162
+
163
+
164
+ def _run_item(
165
+ index,
166
+ item,
167
+ task_file,
168
+ lines,
169
+ ends_with_newline,
170
+ list_file,
171
+ cwd,
172
+ yolo,
173
+ flags,
174
+ counts,
175
+ results,
176
+ progress,
177
+ lock,
178
+ ):
179
+ running_line = f"{_STATUS_RUNNING} {item}"
180
+ with lock:
181
+ lines[index] = running_line
182
+ _write_lines(list_file, lines, ends_with_newline)
183
+ counts["running"] += 1
184
+ progress.set_postfix_str(_status_text(counts))
185
+
186
+ summary = ""
187
+ success = False
188
+ attempts = None
189
+ max_attempts = None
190
+ try:
191
+ task = TaskFile(
192
+ task_file,
193
+ item,
194
+ cwd=cwd,
195
+ yolo=yolo,
196
+ thread_id=None,
197
+ flags=flags,
198
+ )
199
+ max_attempts = task.max_attempts
200
+ result = task()
201
+ success = result.success
202
+ attempts = result.attempts
203
+ summary = result.summary or ""
204
+ except Exception as exc:
205
+ summary = f"{type(exc).__name__}: {exc}"
206
+ success = False
207
+
208
+ summary = _single_line(summary)
209
+ turns = _format_turns(attempts, max_attempts)
210
+ if summary:
211
+ summary = f"{summary} {turns}"
212
+ else:
213
+ summary = turns
214
+ status = _STATUS_SUCCESS if success else _STATUS_FAILED
215
+ final_line = f"{status} {item} | {summary}"
216
+
217
+ with lock:
218
+ lines[index] = final_line
219
+ _write_lines(list_file, lines, ends_with_newline)
220
+ counts["running"] -= 1
221
+ if success:
222
+ counts["success"] += 1
223
+ else:
224
+ counts["failed"] += 1
225
+ results.append((item, success, summary))
226
+ progress.update(1)
227
+ progress.set_postfix_str(_status_text(counts))
228
+ tqdm.write(final_line, file=sys.stdout)
codexapi/ralph.py ADDED
@@ -0,0 +1,335 @@
1
+ """Ralph Wiggum-style loop for Codex runs."""
2
+
3
+ import os
4
+ import re
5
+ import sys
6
+ from datetime import datetime, timezone
7
+
8
+ from .agent import Agent
9
+
10
+ _STATE_DIR = ".codexapi"
11
+ _STATE_FILE = "ralph-loop.local.md"
12
+ _PROMISE_RE = re.compile(r"<promise>(.*?)</promise>", re.DOTALL)
13
+
14
+
15
+ def run_ralph_loop(
16
+ prompt,
17
+ cwd=None,
18
+ yolo=True,
19
+ flags=None,
20
+ max_iterations=0,
21
+ completion_promise=None,
22
+ fresh=True,
23
+ ):
24
+ """Run a Ralph Wiggum-style loop that repeats the same prompt.
25
+
26
+ The loop writes `.codexapi/ralph-loop.local.md` in the target cwd and keeps
27
+ sending the exact same prompt each iteration until one of these happens:
28
+ - A completion promise is matched.
29
+ - `max_iterations` is reached (0 means unlimited).
30
+ - The state file is removed (cancel).
31
+ - An error or KeyboardInterrupt.
32
+
33
+ To complete with a promise, the agent must output:
34
+ <promise>TEXT</promise>
35
+ `TEXT` is trimmed and whitespace-collapsed before an exact match against
36
+ `completion_promise`. CRITICAL RULE: If a completion promise is set, you
37
+ may ONLY output it when the statement is completely and unequivocally TRUE.
38
+ Do not output false promises to escape the loop.
39
+
40
+ By default each iteration uses a fresh Agent for a clean context. Set
41
+ `fresh=False` to reuse a single Agent instance for shared context.
42
+ Cancel by deleting the state file or running `codexapi ralph --cancel`.
43
+ """
44
+ if not isinstance(prompt, str) or not prompt.strip():
45
+ raise ValueError("prompt must be a non-empty string")
46
+ if completion_promise is not None and not isinstance(completion_promise, str):
47
+ raise TypeError("completion_promise must be a string or None")
48
+ if max_iterations < 0:
49
+ raise ValueError("max_iterations must be >= 0")
50
+ if hasattr(sys.stdout, "reconfigure"):
51
+ sys.stdout.reconfigure(line_buffering=True)
52
+
53
+ state_path = _state_path(cwd)
54
+ _ensure_state_dir(state_path)
55
+
56
+ started_at = _utc_now()
57
+ iteration = 1
58
+ _write_state(
59
+ state_path,
60
+ iteration,
61
+ max_iterations,
62
+ completion_promise,
63
+ started_at,
64
+ prompt,
65
+ )
66
+
67
+ max_label = str(max_iterations) if max_iterations > 0 else "unlimited"
68
+ if completion_promise is None:
69
+ promise_label = "none (runs forever)"
70
+ else:
71
+ promise_label = (
72
+ f"{completion_promise} (ONLY output when TRUE - do not lie!)"
73
+ )
74
+
75
+ print(
76
+ "\n".join(
77
+ [
78
+ "Ralph loop activated.",
79
+ "",
80
+ f"Iteration: {iteration}",
81
+ f"Max iterations: {max_label}",
82
+ f"Completion promise: {promise_label}",
83
+ "",
84
+ "The loop will resend the SAME PROMPT each iteration.",
85
+ "Cancel by deleting .codexapi/ralph-loop.local.md or running",
86
+ "codexapi ralph --cancel.",
87
+ "No manual stop beyond max iterations or completion promise.",
88
+ "",
89
+ "To monitor: head -10 .codexapi/ralph-loop.local.md",
90
+ "",
91
+ ]
92
+ )
93
+ )
94
+ print(prompt)
95
+
96
+ if completion_promise is not None:
97
+ print(
98
+ "\n".join(
99
+ [
100
+ "",
101
+ "CRITICAL - Ralph Loop Completion Promise",
102
+ "",
103
+ "To complete this loop, output this EXACT text:",
104
+ f" <promise>{completion_promise}</promise>",
105
+ "",
106
+ "STRICT REQUIREMENTS (DO NOT VIOLATE):",
107
+ " - Use <promise> XML tags EXACTLY as shown above",
108
+ " - The statement MUST be completely and unequivocally TRUE",
109
+ " - Do NOT output false statements to exit the loop",
110
+ " - Do NOT lie even if you think you should exit",
111
+ "",
112
+ "CRITICAL RULE: If a completion promise is set, you may ONLY",
113
+ "output it when the statement is completely and unequivocally",
114
+ "TRUE. Do not output false promises to escape the loop, even if",
115
+ "you think you're stuck or should exit for other reasons. The",
116
+ "loop is designed to continue until genuine completion.",
117
+ "",
118
+ ]
119
+ )
120
+ )
121
+
122
+ runner = None
123
+ last_message = None
124
+ state_missing = False
125
+
126
+ try:
127
+ while True:
128
+ if not os.path.exists(state_path):
129
+ state_missing = True
130
+ print("Ralph loop canceled: state file removed.")
131
+ return last_message
132
+
133
+ print(_status_line(iteration, completion_promise))
134
+
135
+ if fresh:
136
+ runner = Agent(cwd, yolo, None, flags)
137
+ elif runner is None:
138
+ runner = Agent(cwd, yolo, None, flags)
139
+
140
+ message = runner(prompt + '\nIf there are multiple paths forward, you MUST use your own best judgement as to which to try first! Do not ask the user to choose an option, they hereby give you explciit permission to pick the best one yourself.\n')
141
+ print(message)
142
+ last_message = message
143
+
144
+ if not os.path.exists(state_path):
145
+ state_missing = True
146
+ print("Ralph loop canceled: state file removed.")
147
+ return last_message
148
+
149
+ if max_iterations > 0 and iteration >= max_iterations:
150
+ print(f"Ralph loop: Max iterations ({max_iterations}) reached.")
151
+ return message
152
+
153
+ if promise_matches(message, completion_promise):
154
+ print(
155
+ "Ralph loop: Detected "
156
+ f"<promise>{completion_promise}</promise>"
157
+ )
158
+ return message
159
+
160
+ if not os.path.exists(state_path):
161
+ state_missing = True
162
+ print("Ralph loop canceled: state file removed.")
163
+ return last_message
164
+
165
+ iteration += 1
166
+ _write_state(
167
+ state_path,
168
+ iteration,
169
+ max_iterations,
170
+ completion_promise,
171
+ started_at,
172
+ prompt,
173
+ )
174
+ except KeyboardInterrupt:
175
+ print("Ralph loop interrupted.", file=sys.stderr)
176
+ raise SystemExit(130)
177
+ except Exception as exc:
178
+ print(f"Ralph loop stopped: {exc}", file=sys.stderr)
179
+ raise SystemExit(1)
180
+ finally:
181
+ if not state_missing:
182
+ _cleanup_state(state_path)
183
+
184
+
185
+ def cancel_ralph_loop(cwd=None):
186
+ """Cancel the Ralph loop by removing the state file."""
187
+ state_path = _state_path(cwd)
188
+ if not os.path.exists(state_path):
189
+ return "No active Ralph loop state found."
190
+
191
+ iteration = None
192
+ try:
193
+ fields = _read_state_fields(state_path)
194
+ value = fields.get("iteration")
195
+ if value and value.isdigit():
196
+ iteration = int(value)
197
+ except OSError:
198
+ iteration = None
199
+
200
+ _cleanup_state(state_path)
201
+
202
+ if iteration is None:
203
+ return "Canceled Ralph loop."
204
+ return f"Canceled Ralph loop at iteration {iteration}."
205
+
206
+
207
+ def promise_matches(message, completion_promise):
208
+ """Return True when the message contains the matching completion promise."""
209
+ if completion_promise is None:
210
+ return False
211
+ extracted = _extract_promise(message)
212
+ if extracted is None:
213
+ return False
214
+ return extracted == completion_promise
215
+
216
+
217
+ def _extract_promise(message):
218
+ """Extract and normalize the first <promise>...</promise> tag from text."""
219
+ match = _PROMISE_RE.search(message)
220
+ if not match:
221
+ return None
222
+ return _normalize_whitespace(match.group(1))
223
+
224
+
225
+ def _normalize_whitespace(text):
226
+ """Trim and collapse whitespace to match the Claude plugin behavior."""
227
+ return " ".join(text.split())
228
+
229
+
230
+ def _state_path(cwd):
231
+ """Return the absolute path for the Ralph loop state file."""
232
+ root = os.fspath(cwd) if cwd else os.getcwd()
233
+ return os.path.join(root, _STATE_DIR, _STATE_FILE)
234
+
235
+
236
+ def _ensure_state_dir(state_path):
237
+ """Ensure the Ralph loop state directory exists."""
238
+ os.makedirs(os.path.dirname(state_path), exist_ok=True)
239
+
240
+
241
+ def _write_state(
242
+ state_path,
243
+ iteration,
244
+ max_iterations,
245
+ completion_promise,
246
+ started_at,
247
+ prompt,
248
+ ):
249
+ """Write the Ralph loop state file atomically."""
250
+ content = _state_content(
251
+ iteration,
252
+ max_iterations,
253
+ completion_promise,
254
+ started_at,
255
+ prompt,
256
+ )
257
+ temp_path = f"{state_path}.tmp.{os.getpid()}"
258
+ with open(temp_path, "w", encoding="utf-8") as handle:
259
+ handle.write(content)
260
+ os.replace(temp_path, state_path)
261
+
262
+
263
+ def _state_content(
264
+ iteration,
265
+ max_iterations,
266
+ completion_promise,
267
+ started_at,
268
+ prompt,
269
+ ):
270
+ """Build the YAML frontmatter state file content."""
271
+ completion_value = _format_completion_promise(completion_promise)
272
+ lines = [
273
+ "---",
274
+ "active: true",
275
+ f"iteration: {iteration}",
276
+ f"max_iterations: {max_iterations}",
277
+ f"completion_promise: {completion_value}",
278
+ f"started_at: \"{started_at}\"",
279
+ "---",
280
+ "",
281
+ prompt,
282
+ ]
283
+ return "\n".join(lines)
284
+
285
+
286
+ def _format_completion_promise(completion_promise):
287
+ """Format the completion promise to match the plugin frontmatter."""
288
+ if completion_promise is None:
289
+ return "null"
290
+ return f"\"{completion_promise}\""
291
+
292
+
293
+ def _read_state_fields(state_path):
294
+ """Read YAML frontmatter fields from the Ralph loop state file."""
295
+ with open(state_path, "r", encoding="utf-8") as handle:
296
+ lines = handle.read().splitlines()
297
+ if not lines or lines[0].strip() != "---":
298
+ return {}
299
+
300
+ fields = {}
301
+ for line in lines[1:]:
302
+ if line.strip() == "---":
303
+ break
304
+ if ":" not in line:
305
+ continue
306
+ key, value = line.split(":", 1)
307
+ fields[key.strip()] = value.strip()
308
+ return fields
309
+
310
+
311
+ def _cleanup_state(state_path):
312
+ """Remove the Ralph loop state file if it exists."""
313
+ try:
314
+ os.remove(state_path)
315
+ except FileNotFoundError:
316
+ return
317
+
318
+
319
+ def _status_line(iteration, completion_promise):
320
+ """Build the per-iteration status line for the Ralph loop."""
321
+ if completion_promise is None:
322
+ return (
323
+ f"Ralph iteration {iteration} | "
324
+ "No completion promise set - loop runs infinitely"
325
+ )
326
+ return (
327
+ f"Ralph iteration {iteration} | To stop: output "
328
+ f"<promise>{completion_promise}</promise> "
329
+ "(ONLY when statement is TRUE - do not lie to exit!)"
330
+ )
331
+
332
+
333
+ def _utc_now():
334
+ """Return a UTC timestamp string matching the Claude plugin."""
335
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")