codexapi 0.1.8__tar.gz → 0.2.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: codexapi
3
- Version: 0.1.8
3
+ Version: 0.2.0
4
4
  Summary: Minimal Python API for running the Codex CLI.
5
5
  License: MIT
6
6
  Keywords: codex,agent,cli,openai
@@ -81,6 +81,16 @@ Resume a session and print the thread id to stderr:
81
81
  codexapi --thread-id THREAD_ID --print-thread-id "Continue where we left off."
82
82
  ```
83
83
 
84
+ Ralph loop mode repeats the same prompt until a completion promise or a max
85
+ iteration cap is hit (0 means unlimited). Cancel by deleting
86
+ `.codexapi/ralph-loop.local.md` or running `codexapi --ralph-cancel`.
87
+
88
+ ```bash
89
+ codexapi --ralph "Fix the bug." --completion-promise DONE --max-iterations 5
90
+ codexapi --ralph --ralph-fresh "Try again from scratch." --max-iterations 3
91
+ codexapi --ralph-cancel --cwd /path/to/project
92
+ ```
93
+
84
94
  ## API
85
95
 
86
96
  ### `agent(prompt, cwd=None, yolo=False, flags=None) -> str`
@@ -69,6 +69,16 @@ Resume a session and print the thread id to stderr:
69
69
  codexapi --thread-id THREAD_ID --print-thread-id "Continue where we left off."
70
70
  ```
71
71
 
72
+ Ralph loop mode repeats the same prompt until a completion promise or a max
73
+ iteration cap is hit (0 means unlimited). Cancel by deleting
74
+ `.codexapi/ralph-loop.local.md` or running `codexapi --ralph-cancel`.
75
+
76
+ ```bash
77
+ codexapi --ralph "Fix the bug." --completion-promise DONE --max-iterations 5
78
+ codexapi --ralph --ralph-fresh "Try again from scratch." --max-iterations 3
79
+ codexapi --ralph-cancel --cwd /path/to/project
80
+ ```
81
+
72
82
  ## API
73
83
 
74
84
  ### `agent(prompt, cwd=None, yolo=False, flags=None) -> str`
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "codexapi"
7
- version = "0.1.8"
7
+ version = "0.2.0"
8
8
  description = "Minimal Python API for running the Codex CLI."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -12,4 +12,4 @@ __all__ = [
12
12
  "task",
13
13
  "task_result",
14
14
  ]
15
- __version__ = "0.1.8"
15
+ __version__ = "0.2.0"
@@ -12,6 +12,7 @@ from datetime import datetime
12
12
  from pathlib import Path
13
13
 
14
14
  from .agent import Agent, agent
15
+ from .ralph import cancel_ralph_loop, run_ralph_loop
15
16
  from .task import TaskFailed, task
16
17
 
17
18
  _SESSION_ID_RE = re.compile(
@@ -86,9 +87,12 @@ def _parse_timestamp(value):
86
87
  if value.endswith("Z"):
87
88
  value = value[:-1] + "+00:00"
88
89
  try:
89
- return datetime.fromisoformat(value)
90
+ parsed = datetime.fromisoformat(value)
90
91
  except ValueError:
91
92
  return None
93
+ if parsed.tzinfo is None:
94
+ return parsed
95
+ return parsed.astimezone().replace(tzinfo=None)
92
96
 
93
97
 
94
98
  def _tail_lines(path):
@@ -869,9 +873,22 @@ def main(argv=None):
869
873
  if argv and argv[0] == "top":
870
874
  _run_top(argv[1:])
871
875
  return
876
+ ralph_help = (
877
+ "Ralph loop mode (--ralph):\n"
878
+ " Repeats the exact same prompt each iteration until a completion promise\n"
879
+ " is detected or --max-iterations is reached (0 means unlimited).\n"
880
+ " Completion promise: output <promise>TEXT</promise> where TEXT matches\n"
881
+ " --completion-promise after trimming/collapsing whitespace. CRITICAL RULE:\n"
882
+ " Only output the promise when it is completely and unequivocally TRUE.\n"
883
+ " Cancel by deleting .codexapi/ralph-loop.local.md or running --ralph-cancel.\n"
884
+ " Default reuses a single Codex thread; use --ralph-fresh for a new Agent\n"
885
+ " each iteration (no shared context).\n"
886
+ )
872
887
  parser = argparse.ArgumentParser(
873
888
  prog="codexapi",
874
889
  description="Run Codex via the codexapi wrapper.",
890
+ epilog=ralph_help,
891
+ formatter_class=argparse.RawDescriptionHelpFormatter,
875
892
  )
876
893
  parser.add_argument(
877
894
  "prompt",
@@ -902,10 +919,67 @@ def main(argv=None):
902
919
  action="store_true",
903
920
  help="Print the current thread id to stderr after running.",
904
921
  )
922
+ parser.add_argument(
923
+ "--ralph",
924
+ action="store_true",
925
+ help="Run a Ralph loop that repeats the same prompt each iteration.",
926
+ )
927
+ parser.add_argument(
928
+ "--max-iterations",
929
+ type=int,
930
+ default=None,
931
+ help="Max iterations for --ralph (0 means unlimited).",
932
+ )
933
+ parser.add_argument(
934
+ "--completion-promise",
935
+ help="Promise text for --ralph to match in <promise>...</promise>.",
936
+ )
937
+ parser.add_argument(
938
+ "--ralph-fresh",
939
+ action="store_true",
940
+ help="With --ralph, start each iteration with a fresh Agent context.",
941
+ )
942
+ parser.add_argument(
943
+ "--ralph-cancel",
944
+ action="store_true",
945
+ help=(
946
+ "Cancel a Ralph loop by removing .codexapi/ralph-loop.local.md "
947
+ "(respects --cwd)."
948
+ ),
949
+ )
905
950
 
906
951
  args = parser.parse_args(argv)
952
+ if args.ralph_cancel:
953
+ if (
954
+ args.ralph
955
+ or args.task
956
+ or args.thread_id
957
+ or args.print_thread_id
958
+ or args.max_iterations is not None
959
+ or args.completion_promise is not None
960
+ or args.ralph_fresh
961
+ or args.check is not None
962
+ or args.prompt
963
+ ):
964
+ raise SystemExit(
965
+ "--ralph-cancel cannot be combined with prompts or other modes."
966
+ )
967
+ print(cancel_ralph_loop(args.cwd))
968
+ return
907
969
  if args.check is not None and not args.task:
908
970
  raise SystemExit("--check requires --task.")
971
+ if not args.ralph and (
972
+ args.max_iterations is not None
973
+ or args.completion_promise is not None
974
+ or args.ralph_fresh
975
+ ):
976
+ raise SystemExit(
977
+ "--max-iterations/--completion-promise/--ralph-fresh require --ralph."
978
+ )
979
+ if args.ralph and (args.task or args.thread_id or args.print_thread_id):
980
+ raise SystemExit(
981
+ "--task/--thread-id/--print-thread-id are not supported with --ralph."
982
+ )
909
983
  if args.task and (args.thread_id or args.print_thread_id):
910
984
  raise SystemExit("--thread-id/--print-thread-id are not supported with --task.")
911
985
 
@@ -913,6 +987,20 @@ def main(argv=None):
913
987
 
914
988
  exit_code = 0
915
989
 
990
+ if args.ralph:
991
+ max_iterations = args.max_iterations if args.max_iterations is not None else 0
992
+ if max_iterations < 0:
993
+ raise SystemExit("--max-iterations must be >= 0.")
994
+ run_ralph_loop(
995
+ prompt,
996
+ args.cwd,
997
+ args.yolo,
998
+ args.flags,
999
+ max_iterations,
1000
+ args.completion_promise,
1001
+ args.ralph_fresh,
1002
+ )
1003
+ return
916
1004
  if args.task:
917
1005
  check = args.check if args.check is not None else prompt
918
1006
  try:
@@ -0,0 +1,333 @@
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=False,
19
+ flags=None,
20
+ max_iterations=0,
21
+ completion_promise=None,
22
+ fresh=False,
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 a single Agent instance is reused for shared context. Set
41
+ `fresh=True` to create a new Agent each iteration for a clean 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
+
51
+ state_path = _state_path(cwd)
52
+ _ensure_state_dir(state_path)
53
+
54
+ started_at = _utc_now()
55
+ iteration = 1
56
+ _write_state(
57
+ state_path,
58
+ iteration,
59
+ max_iterations,
60
+ completion_promise,
61
+ started_at,
62
+ prompt,
63
+ )
64
+
65
+ max_label = str(max_iterations) if max_iterations > 0 else "unlimited"
66
+ if completion_promise is None:
67
+ promise_label = "none (runs forever)"
68
+ else:
69
+ promise_label = (
70
+ f"{completion_promise} (ONLY output when TRUE - do not lie!)"
71
+ )
72
+
73
+ print(
74
+ "\n".join(
75
+ [
76
+ "Ralph loop activated.",
77
+ "",
78
+ f"Iteration: {iteration}",
79
+ f"Max iterations: {max_label}",
80
+ f"Completion promise: {promise_label}",
81
+ "",
82
+ "The loop will resend the SAME PROMPT each iteration.",
83
+ "Cancel by deleting .codexapi/ralph-loop.local.md or running",
84
+ "codexapi --ralph-cancel.",
85
+ "No manual stop beyond max iterations or completion promise.",
86
+ "",
87
+ "To monitor: head -10 .codexapi/ralph-loop.local.md",
88
+ "",
89
+ ]
90
+ )
91
+ )
92
+ print(prompt)
93
+
94
+ if completion_promise is not None:
95
+ print(
96
+ "\n".join(
97
+ [
98
+ "",
99
+ "CRITICAL - Ralph Loop Completion Promise",
100
+ "",
101
+ "To complete this loop, output this EXACT text:",
102
+ f" <promise>{completion_promise}</promise>",
103
+ "",
104
+ "STRICT REQUIREMENTS (DO NOT VIOLATE):",
105
+ " - Use <promise> XML tags EXACTLY as shown above",
106
+ " - The statement MUST be completely and unequivocally TRUE",
107
+ " - Do NOT output false statements to exit the loop",
108
+ " - Do NOT lie even if you think you should exit",
109
+ "",
110
+ "CRITICAL RULE: If a completion promise is set, you may ONLY",
111
+ "output it when the statement is completely and unequivocally",
112
+ "TRUE. Do not output false promises to escape the loop, even if",
113
+ "you think you're stuck or should exit for other reasons. The",
114
+ "loop is designed to continue until genuine completion.",
115
+ "",
116
+ ]
117
+ )
118
+ )
119
+
120
+ runner = None
121
+ last_message = None
122
+ state_missing = False
123
+
124
+ try:
125
+ while True:
126
+ if not os.path.exists(state_path):
127
+ state_missing = True
128
+ print("Ralph loop canceled: state file removed.")
129
+ return last_message
130
+
131
+ print(_status_line(iteration, completion_promise))
132
+
133
+ if fresh:
134
+ runner = Agent(cwd, yolo, None, flags)
135
+ elif runner is None:
136
+ runner = Agent(cwd, yolo, None, flags)
137
+
138
+ message = runner(prompt)
139
+ print(message)
140
+ last_message = message
141
+
142
+ if not os.path.exists(state_path):
143
+ state_missing = True
144
+ print("Ralph loop canceled: state file removed.")
145
+ return last_message
146
+
147
+ if max_iterations > 0 and iteration >= max_iterations:
148
+ print(f"Ralph loop: Max iterations ({max_iterations}) reached.")
149
+ return message
150
+
151
+ if promise_matches(message, completion_promise):
152
+ print(
153
+ "Ralph loop: Detected "
154
+ f"<promise>{completion_promise}</promise>"
155
+ )
156
+ return message
157
+
158
+ if not os.path.exists(state_path):
159
+ state_missing = True
160
+ print("Ralph loop canceled: state file removed.")
161
+ return last_message
162
+
163
+ iteration += 1
164
+ _write_state(
165
+ state_path,
166
+ iteration,
167
+ max_iterations,
168
+ completion_promise,
169
+ started_at,
170
+ prompt,
171
+ )
172
+ except KeyboardInterrupt:
173
+ print("Ralph loop interrupted.", file=sys.stderr)
174
+ raise SystemExit(130)
175
+ except Exception as exc:
176
+ print(f"Ralph loop stopped: {exc}", file=sys.stderr)
177
+ raise SystemExit(1)
178
+ finally:
179
+ if not state_missing:
180
+ _cleanup_state(state_path)
181
+
182
+
183
+ def cancel_ralph_loop(cwd=None):
184
+ """Cancel the Ralph loop by removing the state file."""
185
+ state_path = _state_path(cwd)
186
+ if not os.path.exists(state_path):
187
+ return "No active Ralph loop state found."
188
+
189
+ iteration = None
190
+ try:
191
+ fields = _read_state_fields(state_path)
192
+ value = fields.get("iteration")
193
+ if value and value.isdigit():
194
+ iteration = int(value)
195
+ except OSError:
196
+ iteration = None
197
+
198
+ _cleanup_state(state_path)
199
+
200
+ if iteration is None:
201
+ return "Canceled Ralph loop."
202
+ return f"Canceled Ralph loop at iteration {iteration}."
203
+
204
+
205
+ def promise_matches(message, completion_promise):
206
+ """Return True when the message contains the matching completion promise."""
207
+ if completion_promise is None:
208
+ return False
209
+ extracted = _extract_promise(message)
210
+ if extracted is None:
211
+ return False
212
+ return extracted == completion_promise
213
+
214
+
215
+ def _extract_promise(message):
216
+ """Extract and normalize the first <promise>...</promise> tag from text."""
217
+ match = _PROMISE_RE.search(message)
218
+ if not match:
219
+ return None
220
+ return _normalize_whitespace(match.group(1))
221
+
222
+
223
+ def _normalize_whitespace(text):
224
+ """Trim and collapse whitespace to match the Claude plugin behavior."""
225
+ return " ".join(text.split())
226
+
227
+
228
+ def _state_path(cwd):
229
+ """Return the absolute path for the Ralph loop state file."""
230
+ root = os.fspath(cwd) if cwd else os.getcwd()
231
+ return os.path.join(root, _STATE_DIR, _STATE_FILE)
232
+
233
+
234
+ def _ensure_state_dir(state_path):
235
+ """Ensure the Ralph loop state directory exists."""
236
+ os.makedirs(os.path.dirname(state_path), exist_ok=True)
237
+
238
+
239
+ def _write_state(
240
+ state_path,
241
+ iteration,
242
+ max_iterations,
243
+ completion_promise,
244
+ started_at,
245
+ prompt,
246
+ ):
247
+ """Write the Ralph loop state file atomically."""
248
+ content = _state_content(
249
+ iteration,
250
+ max_iterations,
251
+ completion_promise,
252
+ started_at,
253
+ prompt,
254
+ )
255
+ temp_path = f"{state_path}.tmp.{os.getpid()}"
256
+ with open(temp_path, "w", encoding="utf-8") as handle:
257
+ handle.write(content)
258
+ os.replace(temp_path, state_path)
259
+
260
+
261
+ def _state_content(
262
+ iteration,
263
+ max_iterations,
264
+ completion_promise,
265
+ started_at,
266
+ prompt,
267
+ ):
268
+ """Build the YAML frontmatter state file content."""
269
+ completion_value = _format_completion_promise(completion_promise)
270
+ lines = [
271
+ "---",
272
+ "active: true",
273
+ f"iteration: {iteration}",
274
+ f"max_iterations: {max_iterations}",
275
+ f"completion_promise: {completion_value}",
276
+ f"started_at: \"{started_at}\"",
277
+ "---",
278
+ "",
279
+ prompt,
280
+ ]
281
+ return "\n".join(lines)
282
+
283
+
284
+ def _format_completion_promise(completion_promise):
285
+ """Format the completion promise to match the plugin frontmatter."""
286
+ if completion_promise is None:
287
+ return "null"
288
+ return f"\"{completion_promise}\""
289
+
290
+
291
+ def _read_state_fields(state_path):
292
+ """Read YAML frontmatter fields from the Ralph loop state file."""
293
+ with open(state_path, "r", encoding="utf-8") as handle:
294
+ lines = handle.read().splitlines()
295
+ if not lines or lines[0].strip() != "---":
296
+ return {}
297
+
298
+ fields = {}
299
+ for line in lines[1:]:
300
+ if line.strip() == "---":
301
+ break
302
+ if ":" not in line:
303
+ continue
304
+ key, value = line.split(":", 1)
305
+ fields[key.strip()] = value.strip()
306
+ return fields
307
+
308
+
309
+ def _cleanup_state(state_path):
310
+ """Remove the Ralph loop state file if it exists."""
311
+ try:
312
+ os.remove(state_path)
313
+ except FileNotFoundError:
314
+ return
315
+
316
+
317
+ def _status_line(iteration, completion_promise):
318
+ """Build the per-iteration status line for the Ralph loop."""
319
+ if completion_promise is None:
320
+ return (
321
+ f"Ralph iteration {iteration} | "
322
+ "No completion promise set - loop runs infinitely"
323
+ )
324
+ return (
325
+ f"Ralph iteration {iteration} | To stop: output "
326
+ f"<promise>{completion_promise}</promise> "
327
+ "(ONLY when statement is TRUE - do not lie to exit!)"
328
+ )
329
+
330
+
331
+ def _utc_now():
332
+ """Return a UTC timestamp string matching the Claude plugin."""
333
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: codexapi
3
- Version: 0.1.8
3
+ Version: 0.2.0
4
4
  Summary: Minimal Python API for running the Codex CLI.
5
5
  License: MIT
6
6
  Keywords: codex,agent,cli,openai
@@ -81,6 +81,16 @@ Resume a session and print the thread id to stderr:
81
81
  codexapi --thread-id THREAD_ID --print-thread-id "Continue where we left off."
82
82
  ```
83
83
 
84
+ Ralph loop mode repeats the same prompt until a completion promise or a max
85
+ iteration cap is hit (0 means unlimited). Cancel by deleting
86
+ `.codexapi/ralph-loop.local.md` or running `codexapi --ralph-cancel`.
87
+
88
+ ```bash
89
+ codexapi --ralph "Fix the bug." --completion-promise DONE --max-iterations 5
90
+ codexapi --ralph --ralph-fresh "Try again from scratch." --max-iterations 3
91
+ codexapi --ralph-cancel --cwd /path/to/project
92
+ ```
93
+
84
94
  ## API
85
95
 
86
96
  ### `agent(prompt, cwd=None, yolo=False, flags=None) -> str`
@@ -5,6 +5,7 @@ src/codexapi/__init__.py
5
5
  src/codexapi/__main__.py
6
6
  src/codexapi/agent.py
7
7
  src/codexapi/cli.py
8
+ src/codexapi/ralph.py
8
9
  src/codexapi/task.py
9
10
  src/codexapi.egg-info/PKG-INFO
10
11
  src/codexapi.egg-info/SOURCES.txt
File without changes
File without changes
File without changes
File without changes