python-codex 0.1.3__py3-none-any.whl → 0.1.5__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.
@@ -0,0 +1,483 @@
1
+ import json
2
+ import os
3
+ import re
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+
7
+ from ..protocol import (
8
+ AssistantMessage,
9
+ ConversationItem,
10
+ ReasoningItem,
11
+ ToolCall,
12
+ ToolResult,
13
+ UserMessage,
14
+ )
15
+ from .get_env import get_package_version
16
+ from .visualize import shorten_title
17
+ import typing
18
+
19
+ SESSION_INDEX_FILENAME = "session_index.jsonl"
20
+ ROLLUP_SESSION_DIRNAMES = ("sessions", "archived_sessions")
21
+ UUID_PATTERN = re.compile(
22
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
23
+ re.IGNORECASE,
24
+ )
25
+
26
+
27
+ def resolve_codex_home(
28
+ config_path: 'typing.Union[str, None]' = None,
29
+ ) -> 'Path':
30
+ if config_path:
31
+ return Path(config_path).expanduser().resolve().parent
32
+ codex_home = os.environ.get("CODEX_HOME", "").strip()
33
+ if codex_home:
34
+ return Path(codex_home).expanduser().resolve()
35
+ return Path.home() / ".codex"
36
+
37
+
38
+ class SessionRolloutRecorder:
39
+ def __init__(self, rollout_path: 'Path') -> 'None':
40
+ self.rollout_path = rollout_path
41
+
42
+ @classmethod
43
+ def create(
44
+ cls,
45
+ codex_home: 'Path',
46
+ session_id: 'str',
47
+ cwd: 'Path',
48
+ originator: 'str',
49
+ model_provider: 'typing.Union[str, None]',
50
+ base_instructions: 'str',
51
+ ) -> 'SessionRolloutRecorder':
52
+ recorder = cls(_rollout_path_for_session(codex_home, session_id))
53
+ recorder.write_session_meta(
54
+ session_id=session_id,
55
+ cwd=cwd,
56
+ originator=originator,
57
+ model_provider=model_provider,
58
+ base_instructions=base_instructions,
59
+ )
60
+ return recorder
61
+
62
+ @classmethod
63
+ def resume(
64
+ cls,
65
+ rollout_path: 'typing.Union[str, Path]',
66
+ ) -> 'SessionRolloutRecorder':
67
+ return cls(Path(rollout_path))
68
+
69
+ def write_session_meta(
70
+ self,
71
+ session_id: 'str',
72
+ cwd: 'Path',
73
+ originator: 'str',
74
+ model_provider: 'typing.Union[str, None]',
75
+ base_instructions: 'str',
76
+ ) -> 'None':
77
+ payload = {
78
+ "id": session_id,
79
+ "timestamp": _timestamp_string(),
80
+ "cwd": str(cwd),
81
+ "originator": originator,
82
+ "cli_version": get_package_version(),
83
+ "source": "cli",
84
+ "model_provider": model_provider,
85
+ "base_instructions": {"text": base_instructions},
86
+ }
87
+ self._append_line("session_meta", payload)
88
+
89
+ def append_history_items(
90
+ self,
91
+ items: 'typing.Iterable[ConversationItem]',
92
+ ) -> 'None':
93
+ for item in items:
94
+ self.append_history_item(item)
95
+
96
+ def append_history_item(self, item: 'ConversationItem') -> 'None':
97
+ if isinstance(item, UserMessage):
98
+ self._append_line("response_item", item.serialize())
99
+ self._append_line(
100
+ "event_msg",
101
+ {
102
+ "type": "user_message",
103
+ "message": item.text,
104
+ "images": [],
105
+ "local_images": [],
106
+ "text_elements": [],
107
+ },
108
+ )
109
+ return
110
+ if isinstance(item, ToolResult):
111
+ self._append_line("response_item", item.serialize())
112
+ return
113
+ serialized = item.serialize()
114
+ if isinstance(serialized, dict):
115
+ self._append_line("response_item", serialized)
116
+
117
+ def append_compacted_history(
118
+ self,
119
+ history: 'typing.Iterable[ConversationItem]',
120
+ ) -> 'None':
121
+ serialized_items = []
122
+ for item in history:
123
+ serialized = item.serialize()
124
+ if isinstance(serialized, dict):
125
+ serialized_items.append(serialized)
126
+ self._append_line(
127
+ "compacted",
128
+ {"replacement_history": serialized_items},
129
+ )
130
+
131
+ def _append_line(self, item_type: 'str', payload: 'typing.Dict[str, object]') -> 'None':
132
+ self.rollout_path.parent.mkdir(parents=True, exist_ok=True)
133
+ line = {
134
+ "timestamp": _timestamp_string(),
135
+ "type": item_type,
136
+ "payload": payload,
137
+ }
138
+ with self.rollout_path.open("a", encoding="utf-8") as handle:
139
+ handle.write(json.dumps(line, ensure_ascii=False, separators=(",", ":")))
140
+ handle.write("\n")
141
+ handle.flush()
142
+
143
+
144
+ def list_resumable_sessions(
145
+ codex_home: 'Path',
146
+ limit: 'int' = 20,
147
+ ) -> 'typing.Tuple[typing.Dict[str, str], ...]':
148
+ latest_rollouts_by_id: 'typing.Dict[str, Path]' = {}
149
+ for dirname in ROLLUP_SESSION_DIRNAMES:
150
+ root = codex_home / dirname
151
+ if not root.exists():
152
+ continue
153
+ for path in root.rglob("rollout-*.jsonl"):
154
+ thread_id = _thread_id_from_rollout_path(path)
155
+ if thread_id is None:
156
+ continue
157
+ previous = latest_rollouts_by_id.get(thread_id)
158
+ if previous is None or path.stat().st_mtime > previous.stat().st_mtime:
159
+ latest_rollouts_by_id[thread_id] = path
160
+
161
+ latest_names_by_id = _latest_thread_names_by_id(codex_home)
162
+ ordered_paths = sorted(
163
+ latest_rollouts_by_id.items(),
164
+ key=lambda item: (item[1].stat().st_mtime, str(item[1])),
165
+ reverse=True,
166
+ )
167
+ sessions: 'typing.List[typing.Dict[str, str]]' = []
168
+ for thread_id, path in ordered_paths[:limit]:
169
+ thread_name = latest_names_by_id.get(thread_id, "")
170
+ preview = _extract_first_user_message_preview(path)
171
+ if preview is None:
172
+ continue
173
+ sessions.append(
174
+ {
175
+ "thread_id": thread_id,
176
+ "title": thread_name or preview,
177
+ "preview": preview,
178
+ "rollout_path": str(path),
179
+ }
180
+ )
181
+ return tuple(sessions)
182
+
183
+
184
+ def load_resumed_session(
185
+ codex_home: 'Path',
186
+ resume_index_text: 'str',
187
+ ) -> 'typing.Dict[str, object]':
188
+ normalized_target = resume_index_text.strip()
189
+ if not normalized_target.isdigit():
190
+ raise ValueError("Usage: /resume <number>")
191
+
192
+ sessions = list_resumable_sessions(codex_home)
193
+ resume_index = int(normalized_target)
194
+ if resume_index < 1 or resume_index > len(sessions):
195
+ raise ValueError(f"Session not found: {normalized_target}")
196
+
197
+ session = sessions[resume_index - 1]
198
+ thread_id = session["thread_id"]
199
+ rollout_path = Path(session["rollout_path"])
200
+ thread_name = _latest_thread_names_by_id(codex_home).get(thread_id)
201
+ session_id = thread_id
202
+ history: 'typing.List[ConversationItem]' = []
203
+ saw_user_turn = False
204
+ tool_names_by_call_id: 'typing.Dict[str, str]' = {}
205
+
206
+ for entry in _iter_rollout_entries(rollout_path):
207
+ item_type = str(entry.get("type", "")).strip()
208
+ payload = entry.get("payload")
209
+
210
+ if item_type == "session_meta" and isinstance(payload, dict):
211
+ session_id = str(payload.get("id", "")).strip() or session_id
212
+ continue
213
+
214
+ if item_type == "compacted" and isinstance(payload, dict):
215
+ replacement_history = payload.get("replacement_history")
216
+ if isinstance(replacement_history, list):
217
+ history = _deserialize_compacted_history(replacement_history)
218
+ saw_user_turn = any(
219
+ isinstance(item, UserMessage) for item in history
220
+ )
221
+ tool_names_by_call_id = {
222
+ item.call_id: item.name
223
+ for item in history
224
+ if isinstance(item, ToolCall)
225
+ }
226
+ continue
227
+
228
+ if item_type == "event_msg" and isinstance(payload, dict):
229
+ if payload.get("type") == "user_message":
230
+ history.append(UserMessage(text=str(payload.get("message", ""))))
231
+ saw_user_turn = True
232
+ continue
233
+
234
+ if item_type != "response_item" or not saw_user_turn or not isinstance(payload, dict):
235
+ continue
236
+
237
+ _append_deserialized_response_item(
238
+ history,
239
+ payload,
240
+ tool_names_by_call_id,
241
+ include_user_messages=False,
242
+ )
243
+
244
+ if not history:
245
+ raise ValueError(f"No resumable history found in {rollout_path}")
246
+
247
+ turns = conversation_history_to_turns(history)
248
+ title = thread_name or (shorten_title(turns[0][0]) if turns else thread_id)
249
+ return {
250
+ "session_id": session_id,
251
+ "thread_id": thread_id,
252
+ "title": title,
253
+ "history": tuple(history),
254
+ "turns": tuple(turns),
255
+ "rollout_path": rollout_path,
256
+ }
257
+
258
+
259
+ def conversation_history_to_turns(
260
+ history: 'typing.Iterable[ConversationItem]',
261
+ ) -> 'typing.Tuple[typing.Tuple[str, str], ...]':
262
+ turns: 'typing.List[typing.Tuple[str, str]]' = []
263
+ current_user_text: 'typing.Union[str, None]' = None
264
+ current_assistant_text = ""
265
+ for item in history:
266
+ if isinstance(item, UserMessage):
267
+ if current_user_text is not None:
268
+ turns.append((current_user_text, current_assistant_text))
269
+ current_user_text = item.text
270
+ current_assistant_text = ""
271
+ continue
272
+ if isinstance(item, AssistantMessage) and current_user_text is not None:
273
+ current_assistant_text = item.text
274
+ if current_user_text is not None:
275
+ turns.append((current_user_text, current_assistant_text))
276
+ return tuple(turns)
277
+
278
+
279
+ def _latest_thread_names_by_id(codex_home: 'Path') -> 'typing.Dict[str, str]':
280
+ index_path = codex_home / SESSION_INDEX_FILENAME
281
+ if not index_path.exists():
282
+ return {}
283
+
284
+ names_by_id: 'typing.Dict[str, str]' = {}
285
+ for raw_line in reversed(index_path.read_text().splitlines()):
286
+ line = raw_line.strip()
287
+ if not line:
288
+ continue
289
+ try:
290
+ entry = json.loads(line)
291
+ except json.JSONDecodeError:
292
+ continue
293
+ if not isinstance(entry, dict):
294
+ continue
295
+ thread_id = str(entry.get("id", "")).strip()
296
+ thread_name = str(entry.get("thread_name", "")).strip()
297
+ if thread_id and thread_name and thread_id not in names_by_id:
298
+ names_by_id[thread_id] = thread_name
299
+ return names_by_id
300
+
301
+
302
+ def _thread_id_from_rollout_path(path: 'Path') -> 'typing.Union[str, None]':
303
+ stem = path.stem
304
+ if len(stem) < 36:
305
+ return None
306
+ candidate = stem[-36:]
307
+ return candidate if UUID_PATTERN.match(candidate) else None
308
+
309
+
310
+ def _extract_first_user_message_preview(rollout_path: 'Path') -> 'typing.Union[str, None]':
311
+ for entry in _iter_rollout_entries(rollout_path):
312
+ if entry.get("type") != "event_msg":
313
+ continue
314
+ payload = entry.get("payload")
315
+ if not isinstance(payload, dict) or payload.get("type") != "user_message":
316
+ continue
317
+ message = str(payload.get("message", "")).strip()
318
+ if message:
319
+ return shorten_title(message, limit=72)
320
+ return None
321
+
322
+
323
+ def _iter_rollout_entries(rollout_path: 'Path') -> 'typing.Iterable[typing.Dict[str, object]]':
324
+ text = rollout_path.read_text()
325
+ decoder = json.JSONDecoder()
326
+ index = 0
327
+ parsed_entries = 0
328
+ text_length = len(text)
329
+
330
+ while index < text_length:
331
+ while index < text_length and text[index].isspace():
332
+ index += 1
333
+ if index >= text_length:
334
+ break
335
+ try:
336
+ entry, index = decoder.raw_decode(text, index)
337
+ except json.JSONDecodeError as exc:
338
+ if parsed_entries > 0:
339
+ break
340
+ raise ValueError(f"failed to parse rollout file {rollout_path}: {exc}") from exc
341
+ if isinstance(entry, dict):
342
+ parsed_entries += 1
343
+ yield entry
344
+
345
+ if parsed_entries == 0:
346
+ raise ValueError(f"no rollout entries found in {rollout_path}")
347
+
348
+
349
+ def _extract_response_message_text(payload: 'typing.Dict[str, object]') -> 'str':
350
+ text_parts: 'typing.List[str]' = []
351
+ for item in payload.get("content") or []:
352
+ if isinstance(item, dict) and item.get("type") in {"input_text", "output_text"}:
353
+ text_parts.append(str(item.get("text", "")))
354
+ return "".join(text_parts)
355
+
356
+
357
+ def _deserialize_compacted_history(
358
+ replacement_history: 'typing.Iterable[object]',
359
+ ) -> 'typing.List[ConversationItem]':
360
+ history: 'typing.List[ConversationItem]' = []
361
+ tool_names_by_call_id: 'typing.Dict[str, str]' = {}
362
+ for payload in replacement_history:
363
+ if not isinstance(payload, dict):
364
+ continue
365
+ _append_deserialized_response_item(
366
+ history,
367
+ payload,
368
+ tool_names_by_call_id,
369
+ include_user_messages=True,
370
+ )
371
+ return history
372
+
373
+
374
+ def _append_deserialized_response_item(
375
+ history: 'typing.List[ConversationItem]',
376
+ payload: 'typing.Dict[str, object]',
377
+ tool_names_by_call_id: 'typing.Dict[str, str]',
378
+ include_user_messages: 'bool',
379
+ ) -> 'None':
380
+ response_item_type = str(payload.get("type", "")).strip()
381
+ if response_item_type == "message":
382
+ role = str(payload.get("role", "")).strip()
383
+ if role == "assistant":
384
+ history.append(AssistantMessage(text=_extract_response_message_text(payload)))
385
+ return
386
+ if include_user_messages and role == "user":
387
+ history.append(UserMessage(text=_extract_response_message_text(payload)))
388
+ return
389
+
390
+ if response_item_type == "reasoning":
391
+ history.append(ReasoningItem(payload=dict(payload)))
392
+ return
393
+
394
+ if response_item_type == "function_call":
395
+ raw_arguments = payload.get("arguments", "{}")
396
+ if isinstance(raw_arguments, str):
397
+ try:
398
+ arguments = json.loads(raw_arguments or "{}")
399
+ except json.JSONDecodeError:
400
+ return
401
+ elif isinstance(raw_arguments, dict):
402
+ arguments = dict(raw_arguments)
403
+ else:
404
+ return
405
+ if not isinstance(arguments, dict):
406
+ return
407
+ call_id = str(payload.get("call_id", "")).strip()
408
+ name = str(payload.get("name", "")).strip()
409
+ if not call_id or not name:
410
+ return
411
+ history.append(ToolCall(call_id=call_id, name=name, arguments=arguments))
412
+ tool_names_by_call_id[call_id] = name
413
+ return
414
+
415
+ if response_item_type == "custom_tool_call":
416
+ call_id = str(payload.get("call_id", "")).strip()
417
+ name = str(payload.get("name", "")).strip()
418
+ if not call_id or not name:
419
+ return
420
+ history.append(
421
+ ToolCall(
422
+ call_id=call_id,
423
+ name=name,
424
+ arguments=str(payload.get("input", "")),
425
+ tool_type="custom",
426
+ )
427
+ )
428
+ tool_names_by_call_id[call_id] = name
429
+ return
430
+
431
+ if response_item_type not in {"function_call_output", "custom_tool_call_output"}:
432
+ return
433
+
434
+ call_id = str(payload.get("call_id", "")).strip()
435
+ if not call_id:
436
+ return
437
+ raw_output = payload.get("output", "")
438
+ content_items = None
439
+ if isinstance(raw_output, list) and all(
440
+ isinstance(item, dict) for item in raw_output
441
+ ):
442
+ content_items = tuple(dict(item) for item in raw_output)
443
+ output = json.dumps(raw_output, ensure_ascii=False)
444
+ elif isinstance(raw_output, (dict, list, str, int, float, bool)) or raw_output is None:
445
+ output = raw_output
446
+ else:
447
+ output = str(raw_output)
448
+ history.append(
449
+ ToolResult(
450
+ call_id=call_id,
451
+ name=tool_names_by_call_id.get(call_id, ""),
452
+ output=output,
453
+ content_items=content_items,
454
+ success=(
455
+ payload.get("success")
456
+ if isinstance(payload.get("success"), bool)
457
+ else None
458
+ ),
459
+ tool_type=(
460
+ "custom"
461
+ if response_item_type == "custom_tool_call_output"
462
+ else "function"
463
+ ),
464
+ )
465
+ )
466
+
467
+
468
+ def _rollout_path_for_session(codex_home: 'Path', session_id: 'str') -> 'Path':
469
+ now = datetime.now(timezone.utc)
470
+ return (
471
+ codex_home
472
+ / "sessions"
473
+ / now.strftime("%Y")
474
+ / now.strftime("%m")
475
+ / now.strftime("%d")
476
+ / f"rollout-{now.strftime('%Y-%m-%dT%H-%M-%S')}-{session_id}.jsonl"
477
+ )
478
+
479
+
480
+ def _timestamp_string() -> 'str':
481
+ now = datetime.now(timezone.utc)
482
+ milliseconds = int(now.microsecond / 1000)
483
+ return now.strftime("%Y-%m-%dT%H:%M:%S") + f".{milliseconds:03d}Z"