codexapi 0.5.17__tar.gz → 0.6.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.
Files changed (23) hide show
  1. {codexapi-0.5.17/src/codexapi.egg-info → codexapi-0.6.0}/PKG-INFO +9 -1
  2. {codexapi-0.5.17 → codexapi-0.6.0}/README.md +8 -0
  3. {codexapi-0.5.17 → codexapi-0.6.0}/pyproject.toml +1 -1
  4. {codexapi-0.5.17 → codexapi-0.6.0}/src/codexapi/__init__.py +7 -1
  5. {codexapi-0.5.17 → codexapi-0.6.0}/src/codexapi/cli.py +7 -28
  6. codexapi-0.6.0/src/codexapi/pushover.py +179 -0
  7. codexapi-0.6.0/src/codexapi/ralph.py +374 -0
  8. codexapi-0.6.0/src/codexapi/science.py +301 -0
  9. {codexapi-0.5.17 → codexapi-0.6.0}/src/codexapi/task.py +30 -0
  10. {codexapi-0.5.17 → codexapi-0.6.0/src/codexapi.egg-info}/PKG-INFO +9 -1
  11. {codexapi-0.5.17 → codexapi-0.6.0}/src/codexapi.egg-info/SOURCES.txt +2 -0
  12. codexapi-0.5.17/src/codexapi/ralph.py +0 -335
  13. {codexapi-0.5.17 → codexapi-0.6.0}/LICENSE +0 -0
  14. {codexapi-0.5.17 → codexapi-0.6.0}/setup.cfg +0 -0
  15. {codexapi-0.5.17 → codexapi-0.6.0}/src/codexapi/__main__.py +0 -0
  16. {codexapi-0.5.17 → codexapi-0.6.0}/src/codexapi/agent.py +0 -0
  17. {codexapi-0.5.17 → codexapi-0.6.0}/src/codexapi/foreach.py +0 -0
  18. {codexapi-0.5.17 → codexapi-0.6.0}/src/codexapi/gh_integration.py +0 -0
  19. {codexapi-0.5.17 → codexapi-0.6.0}/src/codexapi/taskfile.py +0 -0
  20. {codexapi-0.5.17 → codexapi-0.6.0}/src/codexapi.egg-info/dependency_links.txt +0 -0
  21. {codexapi-0.5.17 → codexapi-0.6.0}/src/codexapi.egg-info/entry_points.txt +0 -0
  22. {codexapi-0.5.17 → codexapi-0.6.0}/src/codexapi.egg-info/requires.txt +0 -0
  23. {codexapi-0.5.17 → codexapi-0.6.0}/src/codexapi.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: codexapi
3
- Version: 0.5.17
3
+ Version: 0.6.0
4
4
  Summary: Minimal Python API for running the Codex CLI.
5
5
  License: MIT
6
6
  Keywords: codex,agent,cli,openai
@@ -140,12 +140,20 @@ codexapi ralph --cancel --cwd /path/to/project
140
140
 
141
141
  Science mode wraps a short task in a science prompt and runs it through the
142
142
  Ralph loop. It defaults to `--yolo` and expects progress notes in `SCIENCE.md`.
143
+ Each iteration appends the agent output to `LOGBOOK.md` and the runner extracts
144
+ any improved figures of merit for optional notifications.
143
145
 
144
146
  ```bash
145
147
  codexapi science "hyper-optimize the kernel cycles"
146
148
  codexapi science --no-yolo "hyper-optimize the kernel cycles" --max-iterations 3
147
149
  ```
148
150
 
151
+ Optional Pushover notifications: create `~/.pushover` with two non-empty lines.
152
+ Line 1 is your user or group key, line 2 is the app API token. When this file
153
+ exists, Science will send a notification whenever it detects a new best result,
154
+ including the metric values and percent improvement. Task runs will also send a
155
+ ✅/❌ notification with the task summary.
156
+
149
157
  Run a task file across a list file:
150
158
 
151
159
  ```bash
@@ -125,12 +125,20 @@ codexapi ralph --cancel --cwd /path/to/project
125
125
 
126
126
  Science mode wraps a short task in a science prompt and runs it through the
127
127
  Ralph loop. It defaults to `--yolo` and expects progress notes in `SCIENCE.md`.
128
+ Each iteration appends the agent output to `LOGBOOK.md` and the runner extracts
129
+ any improved figures of merit for optional notifications.
128
130
 
129
131
  ```bash
130
132
  codexapi science "hyper-optimize the kernel cycles"
131
133
  codexapi science --no-yolo "hyper-optimize the kernel cycles" --max-iterations 3
132
134
  ```
133
135
 
136
+ Optional Pushover notifications: create `~/.pushover` with two non-empty lines.
137
+ Line 1 is your user or group key, line 2 is the app API token. When this file
138
+ exists, Science will send a notification whenever it detects a new best result,
139
+ including the metric values and percent improvement. Task runs will also send a
140
+ ✅/❌ notification with the task summary.
141
+
134
142
  Run a task file across a list file:
135
143
 
136
144
  ```bash
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "codexapi"
7
- version = "0.5.17"
7
+ version = "0.6.0"
8
8
  description = "Minimal Python API for running the Codex CLI."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -2,11 +2,17 @@
2
2
 
3
3
  from .agent import Agent, agent
4
4
  from .foreach import ForeachResult, foreach
5
+ from .pushover import Pushover
6
+ from .ralph import Ralph
7
+ from .science import Science
5
8
  from .task import Task, TaskFailed, TaskResult, task, task_result
6
9
 
7
10
  __all__ = [
8
11
  "Agent",
9
12
  "ForeachResult",
13
+ "Pushover",
14
+ "Ralph",
15
+ "Science",
10
16
  "Task",
11
17
  "TaskFailed",
12
18
  "TaskResult",
@@ -15,4 +21,4 @@ __all__ = [
15
21
  "task",
16
22
  "task_result",
17
23
  ]
18
- __version__ = "0.5.17"
24
+ __version__ = "0.6.0"
@@ -14,7 +14,8 @@ from pathlib import Path
14
14
 
15
15
  from .agent import Agent, agent
16
16
  from .foreach import foreach
17
- from .ralph import cancel_ralph_loop, run_ralph_loop
17
+ from .ralph import Ralph, cancel_ralph_loop
18
+ from .science import Science
18
19
  from .task import DEFAULT_MAX_ITERATIONS, TaskFailed, task
19
20
  from .taskfile import TaskFile, load_task_file, task_def_uses_item
20
21
 
@@ -26,22 +27,6 @@ _TAIL_MAX_BYTES = 4 * 1024 * 1024
26
27
  _TAIL_MIN_LINES = 200
27
28
  _PROJECT_LOOP_SLEEP = 30
28
29
  _ROLL_OUT_PREFIX = "rollout-"
29
- _SCIENCE_TEMPLATE = (
30
- "Good afternoon! We have a fun task today - take a good look around this repo "
31
- "and review all relevant knowledge you have. Our task is to {task}. We're "
32
- "working step by step in a scientific manner so if there's a SCIENCE.md read "
33
- "that first to understand the progress of the rest of the team so far. Then "
34
- "try as hard as you can to find a good path forwards - run as many experiments "
35
- "as you want and take your time, we have all night. Note down everything you "
36
- "learn that wasn't obvious in a knowledge section in SCIENCE.md and any "
37
- "experiments in a similar section. The aim is to move the ball forwards, "
38
- "either by getting closer to the goal ruling out a hypothesis that doesn't "
39
- "whilst understanding why. Try your best and have fun with this one! If you "
40
- "think of several options, pick one and run with it - I will not be available "
41
- "to make decisions for you, I give you my full permission to explore and make "
42
- "your own best judgement towards our goal! Remember to update SCIENCE.md. "
43
- "Good hunting!"
44
- )
45
30
  _TASK_TEMPLATE = (
46
31
  "prompt: |\n"
47
32
  " Main task prompt. Required. Use {{item}} for per-item values.\n"
@@ -111,11 +96,6 @@ def _single_line(text):
111
96
  return " ".join(text.replace("\r", " ").split())
112
97
 
113
98
 
114
- def _science_prompt(task):
115
- if not isinstance(task, str) or not task.strip():
116
- raise SystemExit("Science task must be a non-empty string.")
117
- return _SCIENCE_TEMPLATE.replace("{task}", task.strip())
118
-
119
99
 
120
100
  def _create_task_template(path):
121
101
  if not isinstance(path, str) or not path.strip():
@@ -1496,7 +1476,7 @@ def main(argv=None):
1496
1476
  if args.command == "ralph":
1497
1477
  if args.max_iterations < 0:
1498
1478
  raise SystemExit("--max-iterations must be >= 0.")
1499
- run_ralph_loop(
1479
+ Ralph(
1500
1480
  prompt,
1501
1481
  args.cwd,
1502
1482
  args.yolo,
@@ -1504,21 +1484,20 @@ def main(argv=None):
1504
1484
  args.max_iterations,
1505
1485
  args.completion_promise,
1506
1486
  args.ralph_fresh,
1507
- )
1487
+ )()
1508
1488
  return
1509
1489
  if args.command == "science":
1510
1490
  if args.max_iterations < 0:
1511
1491
  raise SystemExit("--max-iterations must be >= 0.")
1512
- science_prompt = _science_prompt(prompt)
1513
- run_ralph_loop(
1514
- science_prompt,
1492
+ Science(
1493
+ prompt,
1515
1494
  args.cwd,
1516
1495
  args.yolo,
1517
1496
  args.flags,
1518
1497
  args.max_iterations,
1519
1498
  args.completion_promise,
1520
1499
  args.ralph_fresh,
1521
- )
1500
+ )()
1522
1501
  return
1523
1502
  if args.command == "task":
1524
1503
  if args.project:
@@ -0,0 +1,179 @@
1
+ """Pushover notification helper."""
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ import threading
7
+ import urllib.error
8
+ import urllib.parse
9
+ import urllib.request
10
+
11
+ _PUSHOVER_PATH = "~/.pushover"
12
+ _PUSHOVER_URL = "https://api.pushover.net/1/messages.json"
13
+ _MAX_MESSAGE = 1024
14
+
15
+ _STARTUP_MESSAGE = (
16
+ "Pushover user and app keys read, notifications for task and science enabled."
17
+ )
18
+
19
+
20
+ class Pushover:
21
+ """Send Pushover notifications when configured."""
22
+
23
+ _lock = threading.Lock()
24
+ _state = {}
25
+
26
+ def __init__(self, path=_PUSHOVER_PATH):
27
+ self.path = os.path.expanduser(path)
28
+ self._state_ref = self._state_for_path(self.path)
29
+
30
+ def ensure_ready(self, announce=True):
31
+ state = self._state_ref
32
+ with self._lock:
33
+ if not state["checked"]:
34
+ state["checked"] = True
35
+ if not os.path.exists(self.path):
36
+ state["enabled"] = False
37
+ return False
38
+ try:
39
+ tokens = _load_pushover_tokens(self.path)
40
+ except ValueError as exc:
41
+ state["error"] = f"Pushover config error: {exc}"
42
+ raise SystemExit(state["error"]) from None
43
+ state["tokens"] = tokens
44
+ state["enabled"] = True
45
+ if state["error"]:
46
+ raise SystemExit(state["error"]) from None
47
+ if announce and state["enabled"] and not state["announced"]:
48
+ print(_STARTUP_MESSAGE)
49
+ state["announced"] = True
50
+ return state["enabled"]
51
+
52
+ def send(self, title, message):
53
+ tokens = self._get_tokens()
54
+ if not tokens:
55
+ return False
56
+ user_key, app_token = tokens
57
+ title_text = _single_line(title).strip() or "Codex update"
58
+ message_text = (message or "").strip()
59
+ if not message_text:
60
+ return False
61
+ message_text = _truncate(message_text, _MAX_MESSAGE)
62
+ payload = urllib.parse.urlencode(
63
+ {
64
+ "token": app_token,
65
+ "user": user_key,
66
+ "title": title_text,
67
+ "message": message_text,
68
+ }
69
+ ).encode("utf-8")
70
+ request = urllib.request.Request(_PUSHOVER_URL, data=payload)
71
+ try:
72
+ with urllib.request.urlopen(request, timeout=10) as response:
73
+ body = response.read().decode("utf-8", errors="replace")
74
+ except urllib.error.HTTPError as exc:
75
+ body = exc.read().decode("utf-8", errors="replace")
76
+ _report_pushover_error(body, exc.code)
77
+ return False
78
+ except Exception as exc:
79
+ _warn(f"Pushover notification failed: {exc}")
80
+ return False
81
+ try:
82
+ data = json.loads(body)
83
+ except json.JSONDecodeError:
84
+ _warn("Pushover returned invalid JSON.")
85
+ return False
86
+ if data.get("status") != 1:
87
+ _report_pushover_error(body, None)
88
+ return False
89
+ return True
90
+
91
+ def _get_tokens(self):
92
+ if not self.ensure_ready(announce=False):
93
+ return None
94
+ return self._state_ref["tokens"]
95
+
96
+ @classmethod
97
+ def _state_for_path(cls, path):
98
+ state = cls._state.get(path)
99
+ if state is None:
100
+ state = {
101
+ "checked": False,
102
+ "enabled": False,
103
+ "tokens": None,
104
+ "error": None,
105
+ "announced": False,
106
+ }
107
+ cls._state[path] = state
108
+ return state
109
+
110
+
111
+ def _load_pushover_tokens(path):
112
+ with open(path, "r", encoding="utf-8") as handle:
113
+ lines = [line.strip() for line in handle if line.strip()]
114
+ if len(lines) != 2:
115
+ raise ValueError(
116
+ f"{path} must contain two non-empty lines: user key then app token"
117
+ )
118
+ return lines[0], lines[1]
119
+
120
+
121
+ def _report_pushover_error(body, status_code):
122
+ errors = None
123
+ try:
124
+ data = json.loads(body)
125
+ if isinstance(data, dict):
126
+ errors = data.get("errors")
127
+ except json.JSONDecodeError:
128
+ errors = None
129
+ message = "Pushover notification failed."
130
+ if status_code:
131
+ message = f"{message} HTTP {status_code}."
132
+ detail = _format_pushover_errors(errors)
133
+ if detail:
134
+ message = f"{message} {detail}"
135
+ _warn(message)
136
+
137
+
138
+ def _format_pushover_errors(errors):
139
+ if not errors:
140
+ return ""
141
+ if isinstance(errors, str):
142
+ errors = [errors]
143
+ if not isinstance(errors, list):
144
+ return ""
145
+ cleaned = [str(error).strip() for error in errors if str(error).strip()]
146
+ if not cleaned:
147
+ return ""
148
+ hint = []
149
+ lower = " ".join(cleaned).lower()
150
+ if "user" in lower:
151
+ hint.append("Check the user key on line 1 of ~/.pushover.")
152
+ if "token" in lower or "application" in lower:
153
+ hint.append("Check the app token on line 2 of ~/.pushover.")
154
+ if "message" in lower:
155
+ hint.append("Check that the message is not empty or too long.")
156
+ suffix = " ".join(hint)
157
+ if suffix:
158
+ return f"{'; '.join(cleaned)} {suffix}"
159
+ return "; ".join(cleaned)
160
+
161
+
162
+ def _single_line(text):
163
+ if not text:
164
+ return ""
165
+ return " ".join(str(text).replace("\r", " ").split())
166
+
167
+
168
+ def _truncate(text, limit):
169
+ if not text:
170
+ return ""
171
+ if len(text) <= limit:
172
+ return text
173
+ if limit <= 3:
174
+ return text[:limit]
175
+ return text[: limit - 3] + "..."
176
+
177
+
178
+ def _warn(message):
179
+ print(message, file=sys.stderr)