codexapi 0.5.16__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.16/src/codexapi.egg-info → codexapi-0.6.0}/PKG-INFO +15 -1
  2. {codexapi-0.5.16 → codexapi-0.6.0}/README.md +14 -0
  3. {codexapi-0.5.16 → codexapi-0.6.0}/pyproject.toml +1 -1
  4. {codexapi-0.5.16 → codexapi-0.6.0}/src/codexapi/__init__.py +7 -1
  5. {codexapi-0.5.16 → 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.16 → codexapi-0.6.0}/src/codexapi/task.py +30 -0
  10. {codexapi-0.5.16 → codexapi-0.6.0/src/codexapi.egg-info}/PKG-INFO +15 -1
  11. {codexapi-0.5.16 → codexapi-0.6.0}/src/codexapi.egg-info/SOURCES.txt +2 -0
  12. codexapi-0.5.16/src/codexapi/ralph.py +0 -335
  13. {codexapi-0.5.16 → codexapi-0.6.0}/LICENSE +0 -0
  14. {codexapi-0.5.16 → codexapi-0.6.0}/setup.cfg +0 -0
  15. {codexapi-0.5.16 → codexapi-0.6.0}/src/codexapi/__main__.py +0 -0
  16. {codexapi-0.5.16 → codexapi-0.6.0}/src/codexapi/agent.py +0 -0
  17. {codexapi-0.5.16 → codexapi-0.6.0}/src/codexapi/foreach.py +0 -0
  18. {codexapi-0.5.16 → codexapi-0.6.0}/src/codexapi/gh_integration.py +0 -0
  19. {codexapi-0.5.16 → codexapi-0.6.0}/src/codexapi/taskfile.py +0 -0
  20. {codexapi-0.5.16 → codexapi-0.6.0}/src/codexapi.egg-info/dependency_links.txt +0 -0
  21. {codexapi-0.5.16 → codexapi-0.6.0}/src/codexapi.egg-info/entry_points.txt +0 -0
  22. {codexapi-0.5.16 → codexapi-0.6.0}/src/codexapi.egg-info/requires.txt +0 -0
  23. {codexapi-0.5.16 → 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.16
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
@@ -94,6 +94,12 @@ Take tasks from a GitHub Project (requires `gh-task`):
94
94
  ```bash
95
95
  codexapi task -p owner/projects/3 -n "Your Name" -s Ready task_a.yaml task_b.yaml
96
96
  ```
97
+ Reset owned tasks on a GitHub Project back to Ready:
98
+
99
+ ```bash
100
+ codexapi reset -p owner/projects/3
101
+ codexapi reset -p owner/projects/3 -d # also removes the Progress section
102
+ ```
97
103
 
98
104
  Task labels are derived from task filenames (basename without extension). The
99
105
  issue title/body become `{{item}}` after removing any existing `## Progress`
@@ -134,12 +140,20 @@ codexapi ralph --cancel --cwd /path/to/project
134
140
 
135
141
  Science mode wraps a short task in a science prompt and runs it through the
136
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.
137
145
 
138
146
  ```bash
139
147
  codexapi science "hyper-optimize the kernel cycles"
140
148
  codexapi science --no-yolo "hyper-optimize the kernel cycles" --max-iterations 3
141
149
  ```
142
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
+
143
157
  Run a task file across a list file:
144
158
 
145
159
  ```bash
@@ -79,6 +79,12 @@ Take tasks from a GitHub Project (requires `gh-task`):
79
79
  ```bash
80
80
  codexapi task -p owner/projects/3 -n "Your Name" -s Ready task_a.yaml task_b.yaml
81
81
  ```
82
+ Reset owned tasks on a GitHub Project back to Ready:
83
+
84
+ ```bash
85
+ codexapi reset -p owner/projects/3
86
+ codexapi reset -p owner/projects/3 -d # also removes the Progress section
87
+ ```
82
88
 
83
89
  Task labels are derived from task filenames (basename without extension). The
84
90
  issue title/body become `{{item}}` after removing any existing `## Progress`
@@ -119,12 +125,20 @@ codexapi ralph --cancel --cwd /path/to/project
119
125
 
120
126
  Science mode wraps a short task in a science prompt and runs it through the
121
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.
122
130
 
123
131
  ```bash
124
132
  codexapi science "hyper-optimize the kernel cycles"
125
133
  codexapi science --no-yolo "hyper-optimize the kernel cycles" --max-iterations 3
126
134
  ```
127
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
+
128
142
  Run a task file across a list file:
129
143
 
130
144
  ```bash
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "codexapi"
7
- version = "0.5.16"
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.16"
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)