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/__init__.py +18 -0
- codexapi/__main__.py +5 -0
- codexapi/agent.py +145 -0
- codexapi/cli.py +1321 -0
- codexapi/foreach.py +228 -0
- codexapi/ralph.py +335 -0
- codexapi/task.py +541 -0
- codexapi/taskfile.py +112 -0
- codexapi-0.5.4.dist-info/LICENSE +21 -0
- codexapi-0.5.4.dist-info/METADATA +229 -0
- codexapi-0.5.4.dist-info/RECORD +14 -0
- codexapi-0.5.4.dist-info/WHEEL +5 -0
- codexapi-0.5.4.dist-info/entry_points.txt +2 -0
- codexapi-0.5.4.dist-info/top_level.txt +1 -0
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")
|