python-codex 0.2.6__py3-none-any.whl → 0.3.0__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.
Files changed (84) hide show
  1. pycodex/__init__.py +18 -14
  2. pycodex/agent.py +468 -462
  3. pycodex/bootstrap.py +417 -0
  4. pycodex/cli.py +236 -436
  5. pycodex/compat.py +19 -5
  6. pycodex/context.py +222 -212
  7. pycodex/doctor.py +52 -48
  8. pycodex/events.py +857 -0
  9. pycodex/feishu_card.py +217 -163
  10. pycodex/feishu_link.py +43 -83
  11. pycodex/model.py +329 -252
  12. pycodex/model_metadata.py +19 -7
  13. pycodex/portable.py +90 -52
  14. pycodex/portable_server.py +32 -24
  15. pycodex/prompts/models.json +235 -803
  16. pycodex/protocol.py +177 -137
  17. pycodex/runtime.py +579 -174
  18. pycodex/runtime_services.py +204 -157
  19. pycodex/tools/__init__.py +4 -1
  20. pycodex/tools/apply_patch_tool.py +69 -48
  21. pycodex/tools/base_tool.py +89 -42
  22. pycodex/tools/clock_tool.py +201 -0
  23. pycodex/tools/close_agent_tool.py +2 -2
  24. pycodex/tools/code_mode_manager.py +77 -64
  25. pycodex/tools/exec_command_tool.py +26 -11
  26. pycodex/tools/exec_tool.py +4 -4
  27. pycodex/tools/grep_files_tool.py +12 -10
  28. pycodex/tools/ipython_tool.py +10 -13
  29. pycodex/tools/list_dir_tool.py +13 -9
  30. pycodex/tools/read_file_tool.py +29 -17
  31. pycodex/tools/request_permissions_tool.py +15 -5
  32. pycodex/tools/request_user_input_tool.py +13 -104
  33. pycodex/tools/resume_agent_tool.py +2 -2
  34. pycodex/tools/send_input_tool.py +11 -8
  35. pycodex/tools/shell_command_tool.py +7 -5
  36. pycodex/tools/shell_tool.py +7 -5
  37. pycodex/tools/spawn_agent_tool.py +7 -4
  38. pycodex/tools/unified_exec_manager.py +102 -69
  39. pycodex/tools/update_plan_tool.py +8 -5
  40. pycodex/tools/view_image_tool.py +13 -13
  41. pycodex/tools/wait_agent_tool.py +27 -4
  42. pycodex/tools/wait_tool.py +5 -4
  43. pycodex/tools/web_search_tool.py +4 -2
  44. pycodex/tools/write_stdin_tool.py +12 -11
  45. pycodex/utils/__init__.py +2 -17
  46. pycodex/utils/compactor.py +50 -66
  47. pycodex/utils/debug.py +2 -2
  48. pycodex/utils/dotenv.py +6 -7
  49. pycodex/utils/event_helpers.py +190 -0
  50. pycodex/utils/get_env.py +27 -70
  51. pycodex/utils/image_utils.py +76 -0
  52. pycodex/utils/random_ids.py +1 -2
  53. pycodex/utils/session_persist.py +263 -161
  54. pycodex/utils/truncation.py +21 -45
  55. python_codex-0.3.0.dist-info/METADATA +704 -0
  56. python_codex-0.3.0.dist-info/RECORD +90 -0
  57. responses_server/__init__.py +1 -5
  58. responses_server/__main__.py +0 -1
  59. responses_server/app.py +36 -31
  60. responses_server/config.py +25 -22
  61. responses_server/messages_api.py +96 -49
  62. responses_server/payload_processors.py +25 -19
  63. responses_server/server.py +11 -11
  64. responses_server/session_store.py +14 -11
  65. responses_server/stream_router.py +196 -107
  66. responses_server/tools/custom_adapter.py +17 -16
  67. responses_server/tools/web_search.py +39 -36
  68. responses_server/trajectory_dump.py +51 -13
  69. workspace_server/__main__.py +0 -1
  70. workspace_server/app.py +470 -384
  71. workspace_server/workspace.html +859 -232
  72. workspace_server/workspaces.html +94 -95
  73. workspace_server/workspaces.py +168 -100
  74. pycodex/collaboration.py +0 -20
  75. pycodex/interactive_session.py +0 -415
  76. pycodex/prompts/collaboration_default.md +0 -11
  77. pycodex/prompts/collaboration_plan.md +0 -128
  78. pycodex/utils/toolcall_visualize.py +0 -713
  79. pycodex/utils/visualize.py +0 -553
  80. python_codex-0.2.6.dist-info/METADATA +0 -441
  81. python_codex-0.2.6.dist-info/RECORD +0 -91
  82. {python_codex-0.2.6.dist-info → python_codex-0.3.0.dist-info}/WHEEL +0 -0
  83. {python_codex-0.2.6.dist-info → python_codex-0.3.0.dist-info}/entry_points.txt +0 -0
  84. {python_codex-0.2.6.dist-info → python_codex-0.3.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,20 +1,24 @@
1
1
  import json
2
+ import mmap
2
3
  import os
3
4
  import re
5
+ import typing
6
+ from contextlib import closing
4
7
  from datetime import datetime
5
8
  from pathlib import Path
6
9
 
7
10
  from ..protocol import (
8
11
  AssistantMessage,
12
+ ContextMessage,
9
13
  ConversationItem,
10
14
  ReasoningItem,
11
15
  ToolCall,
12
16
  ToolResult,
13
17
  UserMessage,
14
18
  )
19
+ from .compactor import SUMMARY_PREFIX
20
+ from .event_helpers import shorten_title
15
21
  from .get_env import get_package_version
16
- from .visualize import shorten_title
17
- import typing
18
22
 
19
23
  SESSION_INDEX_FILENAME = "session_index.jsonl"
20
24
  ROLLUP_SESSION_DIRNAMES = ("sessions", "archived_sessions")
@@ -23,11 +27,13 @@ UUID_PATTERN = re.compile(
23
27
  re.IGNORECASE,
24
28
  )
25
29
  ROLLOUT_READ_CHUNK_SIZE = 1024 * 1024
30
+ COMPACTED_RECORD_MARKER = b',"type":"compacted","payload":'
31
+ ROLLOUT_RECORD_PREFIX = b'\n{"timestamp":"'
26
32
 
27
33
 
28
34
  def resolve_codex_home(
29
- config_path: 'typing.Union[str, None]' = None,
30
- ) -> 'Path':
35
+ config_path: "typing.Union[str, None]" = None,
36
+ ) -> "Path":
31
37
  if config_path:
32
38
  return Path(config_path).expanduser().resolve().parent
33
39
  codex_home = os.environ.get("CODEX_HOME", "").strip()
@@ -37,45 +43,32 @@ def resolve_codex_home(
37
43
 
38
44
 
39
45
  class SessionRolloutRecorder:
40
- def __init__(self, rollout_path: 'Path') -> 'None':
46
+ def __init__(self, rollout_path: "Path") -> "None":
41
47
  self.rollout_path = rollout_path
48
+ self._session_meta: "typing.Union[typing.Dict[str, object], None]" = None
42
49
 
43
50
  @classmethod
44
51
  def create(
45
52
  cls,
46
- codex_home: 'Path',
47
- session_id: 'str',
48
- cwd: 'Path',
49
- originator: 'str',
50
- model_provider: 'typing.Union[str, None]',
51
- base_instructions: 'str',
52
- ) -> 'SessionRolloutRecorder':
53
- recorder = cls(_rollout_path_for_session(codex_home, session_id))
54
- recorder.write_session_meta(
55
- session_id=session_id,
56
- cwd=cwd,
57
- originator=originator,
58
- model_provider=model_provider,
59
- base_instructions=base_instructions,
53
+ codex_home: "Path",
54
+ session_id: "str",
55
+ cwd: "Path",
56
+ originator: "str",
57
+ model_provider: "typing.Union[str, None]",
58
+ base_instructions: "str",
59
+ session_file_path: "typing.Union[str, Path, None]" = None,
60
+ ) -> "SessionRolloutRecorder":
61
+ path = (
62
+ Path(session_file_path)
63
+ if session_file_path is not None
64
+ else rollout_path_for_session(codex_home, session_id)
60
65
  )
61
- return recorder
62
-
63
- @classmethod
64
- def resume(
65
- cls,
66
- rollout_path: 'typing.Union[str, Path]',
67
- ) -> 'SessionRolloutRecorder':
68
- return cls(Path(rollout_path))
69
-
70
- def write_session_meta(
71
- self,
72
- session_id: 'str',
73
- cwd: 'Path',
74
- originator: 'str',
75
- model_provider: 'typing.Union[str, None]',
76
- base_instructions: 'str',
77
- ) -> 'None':
78
- payload = {
66
+ recorder = cls(path.expanduser().resolve())
67
+ if recorder.rollout_path.exists():
68
+ raise FileExistsError(
69
+ "session file already exists: {0}".format(recorder.rollout_path)
70
+ )
71
+ recorder._session_meta = {
79
72
  "id": session_id,
80
73
  "timestamp": _timestamp_string(),
81
74
  "cwd": str(cwd),
@@ -85,68 +78,92 @@ class SessionRolloutRecorder:
85
78
  "model_provider": model_provider,
86
79
  "base_instructions": {"text": base_instructions},
87
80
  }
88
- self._append_line("session_meta", payload)
81
+ return recorder
82
+
83
+ @classmethod
84
+ def resume(
85
+ cls,
86
+ rollout_path: "typing.Union[str, Path]",
87
+ ) -> "SessionRolloutRecorder":
88
+ return cls(Path(rollout_path).expanduser().resolve())
89
89
 
90
90
  def append_history_items(
91
91
  self,
92
- items: 'typing.Iterable[ConversationItem]',
93
- ) -> 'None':
92
+ items: "typing.Iterable[ConversationItem]",
93
+ initial_history: "typing.Iterable[ConversationItem]" = (),
94
+ ) -> "None":
95
+ self._append_records(self._history_records(items), initial_history)
96
+
97
+ @staticmethod
98
+ def _history_records(
99
+ items: "typing.Iterable[ConversationItem]",
100
+ ) -> "typing.Iterable[typing.Tuple[str, typing.Dict[str, object]]]":
101
+ history = []
94
102
  for item in items:
95
- self.append_history_item(item)
96
-
97
- def append_history_item(self, item: 'ConversationItem') -> 'None':
98
- if isinstance(item, UserMessage):
99
- self._append_line("response_item", item.serialize())
100
- self._append_line(
101
- "event_msg",
102
- {
103
+ history.append(item)
104
+ if isinstance(item, ContextMessage) and item.role == "user":
105
+ # Preserve a fork's compact summary as context when replayed.
106
+ yield "compacted", _compacted_payload(history)
107
+ continue
108
+ serialized = item.serialize()
109
+ if isinstance(serialized, dict):
110
+ yield "response_item", serialized
111
+ if isinstance(item, UserMessage):
112
+ yield "event_msg", {
103
113
  "type": "user_message",
104
114
  "message": item.text,
105
115
  "images": [],
106
116
  "local_images": [],
107
117
  "text_elements": [],
108
- },
109
- )
110
- return
111
- if isinstance(item, ToolResult):
112
- self._append_line("response_item", item.serialize())
113
- return
114
- serialized = item.serialize()
115
- if isinstance(serialized, dict):
116
- self._append_line("response_item", serialized)
118
+ }
117
119
 
118
120
  def append_compacted_history(
119
121
  self,
120
- history: 'typing.Iterable[ConversationItem]',
121
- ) -> 'None':
122
- serialized_items = []
123
- for item in history:
124
- serialized = item.serialize()
125
- if isinstance(serialized, dict):
126
- serialized_items.append(serialized)
127
- self._append_line(
128
- "compacted",
129
- {"replacement_history": serialized_items},
122
+ history: "typing.Iterable[ConversationItem]",
123
+ initial_history: "typing.Iterable[ConversationItem]" = (),
124
+ ) -> "None":
125
+ self._append_records(
126
+ [("compacted", _compacted_payload(history))],
127
+ initial_history,
130
128
  )
131
129
 
132
- def _append_line(self, item_type: 'str', payload: 'typing.Dict[str, object]') -> 'None':
130
+ def _append_records(
131
+ self,
132
+ records: "typing.Iterable[typing.Tuple[str, typing.Dict[str, object]]]",
133
+ initial_history: "typing.Iterable[ConversationItem]" = (),
134
+ ) -> "None":
135
+ records = list(records)
136
+ if not records:
137
+ return
138
+ mode = "a"
139
+ if self._session_meta is not None:
140
+ mode = "x"
141
+ records = (
142
+ [("session_meta", self._session_meta)]
143
+ + list(self._history_records(initial_history))
144
+ + records
145
+ )
133
146
  self.rollout_path.parent.mkdir(parents=True, exist_ok=True)
134
- line = {
135
- "timestamp": _timestamp_string(),
136
- "type": item_type,
137
- "payload": payload,
138
- }
139
- with self.rollout_path.open("a", encoding="utf-8") as handle:
140
- handle.write(json.dumps(line, ensure_ascii=False, separators=(",", ":")))
141
- handle.write("\n")
142
- handle.flush()
147
+ with self.rollout_path.open(mode, encoding="utf-8") as handle:
148
+ for item_type, payload in records:
149
+ line = {
150
+ "timestamp": _timestamp_string(),
151
+ "type": item_type,
152
+ "payload": payload,
153
+ }
154
+ handle.write(
155
+ json.dumps(line, ensure_ascii=False, separators=(",", ":"))
156
+ )
157
+ handle.write("\n")
158
+ handle.flush()
159
+ self._session_meta = None
143
160
 
144
161
 
145
162
  def list_resumable_sessions(
146
- codex_home: 'Path',
147
- limit: 'int' = 20,
148
- ) -> 'typing.Tuple[typing.Dict[str, str], ...]':
149
- latest_rollouts_by_id: 'typing.Dict[str, Path]' = {}
163
+ codex_home: "Path",
164
+ limit: "int" = 20,
165
+ ) -> "typing.Tuple[typing.Dict[str, str], ...]":
166
+ latest_rollouts_by_id: "typing.Dict[str, Path]" = {}
150
167
  for dirname in ROLLUP_SESSION_DIRNAMES:
151
168
  root = codex_home / dirname
152
169
  if not root.exists():
@@ -165,7 +182,7 @@ def list_resumable_sessions(
165
182
  key=lambda item: (item[1].stat().st_mtime, str(item[1])),
166
183
  reverse=True,
167
184
  )
168
- sessions: 'typing.List[typing.Dict[str, str]]' = []
185
+ sessions: "typing.List[typing.Dict[str, str]]" = []
169
186
  for thread_id, path in ordered_paths[:limit]:
170
187
  thread_name = latest_names_by_id.get(thread_id, "")
171
188
  preview = _extract_first_user_message_preview(path)
@@ -182,10 +199,10 @@ def list_resumable_sessions(
182
199
  return tuple(sessions)
183
200
 
184
201
 
185
- def load_resumed_session(
186
- codex_home: 'Path',
187
- resume_index_text: 'str',
188
- ) -> 'typing.Dict[str, object]':
202
+ def select_resumable_session(
203
+ codex_home: "Path",
204
+ resume_index_text: "str",
205
+ ) -> "typing.Dict[str, str]":
189
206
  normalized_target = resume_index_text.strip()
190
207
  if not normalized_target.isdigit():
191
208
  raise ValueError("Usage: /resume <number>")
@@ -195,27 +212,34 @@ def load_resumed_session(
195
212
  if resume_index < 1 or resume_index > len(sessions):
196
213
  raise ValueError(f"Session not found: {normalized_target}")
197
214
 
198
- session = sessions[resume_index - 1]
199
- thread_id = session["thread_id"]
200
- rollout_path = Path(session["rollout_path"])
201
- return load_resumed_session_path(
202
- rollout_path,
203
- thread_name=_latest_thread_names_by_id(codex_home).get(thread_id),
204
- )
215
+ return sessions[resume_index - 1]
205
216
 
206
217
 
207
218
  def load_resumed_session_path(
208
- rollout_path: 'typing.Union[str, Path]',
209
- thread_name: 'typing.Union[str, None]' = None,
210
- ) -> 'typing.Dict[str, object]':
211
- rollout_path = Path(rollout_path)
219
+ rollout_path: "typing.Union[str, Path]",
220
+ thread_name: "typing.Union[str, None]" = None,
221
+ ) -> "typing.Dict[str, object]":
222
+ rollout_path = Path(rollout_path).expanduser().resolve()
212
223
  thread_id = _thread_id_from_rollout_path(rollout_path) or ""
213
224
  session_id = thread_id
214
- history: 'typing.List[ConversationItem]' = []
225
+ history: "typing.List[ConversationItem]" = []
215
226
  saw_user_turn = False
216
- tool_names_by_call_id: 'typing.Dict[str, str]' = {}
217
-
218
- for entry in _iter_rollout_entries(rollout_path):
227
+ tool_names_by_call_id: "typing.Dict[str, str]" = {}
228
+
229
+ # A rollout is append-only, and a compacted entry replaces everything
230
+ # before it for the next request. Large tool outputs before the latest
231
+ # checkpoint are retained for audit, but do not need to be decoded while
232
+ # restoring the active conversation.
233
+ compacted_offset = _find_last_compacted_offset(rollout_path)
234
+ entry_start_offset = compacted_offset if compacted_offset is not None else 0
235
+ if compacted_offset is not None:
236
+ with closing(_iter_rollout_entries(rollout_path)) as entries:
237
+ first_entry = next(entries)
238
+ metadata = first_entry.get("payload")
239
+ if first_entry.get("type") == "session_meta" and isinstance(metadata, dict):
240
+ session_id = str(metadata.get("id", "")).strip() or session_id
241
+
242
+ for entry in _iter_rollout_entries(rollout_path, entry_start_offset):
219
243
  item_type = str(entry.get("type", "")).strip()
220
244
  payload = entry.get("payload")
221
245
 
@@ -226,9 +250,11 @@ def load_resumed_session_path(
226
250
  if item_type == "compacted" and isinstance(payload, dict):
227
251
  replacement_history = payload.get("replacement_history")
228
252
  if isinstance(replacement_history, list):
229
- history = _deserialize_compacted_history(replacement_history)
253
+ history = _deserialize_compacted_history(
254
+ replacement_history, payload.get("message")
255
+ )
230
256
  saw_user_turn = any(
231
- isinstance(item, UserMessage) for item in history
257
+ isinstance(item, (UserMessage, ContextMessage)) for item in history
232
258
  )
233
259
  tool_names_by_call_id = {
234
260
  item.call_id: item.name
@@ -243,7 +269,11 @@ def load_resumed_session_path(
243
269
  saw_user_turn = True
244
270
  continue
245
271
 
246
- if item_type != "response_item" or not saw_user_turn or not isinstance(payload, dict):
272
+ if (
273
+ item_type != "response_item"
274
+ or not saw_user_turn
275
+ or not isinstance(payload, dict)
276
+ ):
247
277
  continue
248
278
 
249
279
  _append_deserialized_response_item(
@@ -256,12 +286,13 @@ def load_resumed_session_path(
256
286
  if not history:
257
287
  raise ValueError(f"No resumable history found in {rollout_path}")
258
288
 
259
- history = _trim_incomplete_tool_call_tail(history)
289
+ history = _drop_unmatched_tool_calls(history)
260
290
  if not history:
261
291
  raise ValueError(f"No resumable history found in {rollout_path}")
262
292
 
263
293
  turns = conversation_history_to_turns(history)
264
- title = thread_name or (shorten_title(turns[0][0]) if turns else thread_id)
294
+ first_prompt = next((prompt for prompt, _ in turns if prompt), "")
295
+ title = thread_name or shorten_title(first_prompt) or thread_id
265
296
  return {
266
297
  "session_id": session_id,
267
298
  "thread_id": thread_id,
@@ -273,57 +304,46 @@ def load_resumed_session_path(
273
304
 
274
305
 
275
306
  def conversation_history_to_turns(
276
- history: 'typing.Iterable[ConversationItem]',
277
- ) -> 'typing.Tuple[typing.Tuple[str, str], ...]':
278
- turns: 'typing.List[typing.Tuple[str, str]]' = []
279
- current_user_text: 'typing.Union[str, None]' = None
307
+ history: "typing.Iterable[ConversationItem]",
308
+ ) -> "typing.Tuple[typing.Tuple[str, str], ...]":
309
+ turns: "typing.List[typing.Tuple[str, str]]" = []
310
+ current_user_text: "typing.Union[str, None]" = None
280
311
  current_assistant_text = ""
281
312
  for item in history:
282
313
  if isinstance(item, UserMessage):
283
- if current_user_text is not None:
284
- turns.append((current_user_text, current_assistant_text))
314
+ if current_user_text is not None or current_assistant_text:
315
+ turns.append((current_user_text or "", current_assistant_text))
285
316
  current_user_text = item.text
286
317
  current_assistant_text = ""
287
318
  continue
288
- if isinstance(item, AssistantMessage) and current_user_text is not None:
319
+ if isinstance(item, AssistantMessage):
289
320
  current_assistant_text = item.text
290
- if current_user_text is not None:
291
- turns.append((current_user_text, current_assistant_text))
321
+ if current_user_text is not None or current_assistant_text:
322
+ turns.append((current_user_text or "", current_assistant_text))
292
323
  return tuple(turns)
293
324
 
294
325
 
295
- def _trim_incomplete_tool_call_tail(
296
- history: 'typing.List[ConversationItem]',
297
- ) -> 'typing.List[ConversationItem]':
298
- pending_call_ids: 'typing.Set[str]' = set()
299
- call_indexes: 'typing.Dict[str, int]' = {}
300
-
301
- for index, item in enumerate(history):
302
- if isinstance(item, ToolCall):
303
- pending_call_ids.add(item.call_id)
304
- call_indexes[item.call_id] = index
305
- continue
306
- if isinstance(item, ToolResult):
307
- pending_call_ids.discard(item.call_id)
308
-
309
- if not pending_call_ids:
310
- return history
311
-
312
- trim_start = min(call_indexes[call_id] for call_id in pending_call_ids)
313
- while trim_start > 0 and isinstance(
314
- history[trim_start - 1],
315
- (AssistantMessage, ReasoningItem, ToolCall),
316
- ):
317
- trim_start -= 1
318
- return history[:trim_start]
326
+ def _drop_unmatched_tool_calls(
327
+ history: "typing.List[ConversationItem]",
328
+ ) -> "typing.List[ConversationItem]":
329
+ completed_call_ids = {
330
+ item.call_id for item in history if isinstance(item, ToolResult)
331
+ }
332
+ # An interrupted call can precede later saved turns in an append-only rollout.
333
+ # Omit only calls without results; subsequent history is still resumable.
334
+ return [
335
+ item
336
+ for item in history
337
+ if not isinstance(item, ToolCall) or item.call_id in completed_call_ids
338
+ ]
319
339
 
320
340
 
321
- def _latest_thread_names_by_id(codex_home: 'Path') -> 'typing.Dict[str, str]':
341
+ def _latest_thread_names_by_id(codex_home: "Path") -> "typing.Dict[str, str]":
322
342
  index_path = codex_home / SESSION_INDEX_FILENAME
323
343
  if not index_path.exists():
324
344
  return {}
325
345
 
326
- names_by_id: 'typing.Dict[str, str]' = {}
346
+ names_by_id: "typing.Dict[str, str]" = {}
327
347
  with index_path.open("r", encoding="utf-8", errors="replace") as handle:
328
348
  for raw_line in handle:
329
349
  line = raw_line.strip()
@@ -342,7 +362,7 @@ def _latest_thread_names_by_id(codex_home: 'Path') -> 'typing.Dict[str, str]':
342
362
  return names_by_id
343
363
 
344
364
 
345
- def _thread_id_from_rollout_path(path: 'Path') -> 'typing.Union[str, None]':
365
+ def _thread_id_from_rollout_path(path: "Path") -> "typing.Union[str, None]":
346
366
  stem = path.stem
347
367
  if len(stem) < 36:
348
368
  return None
@@ -350,7 +370,9 @@ def _thread_id_from_rollout_path(path: 'Path') -> 'typing.Union[str, None]':
350
370
  return candidate if UUID_PATTERN.match(candidate) else None
351
371
 
352
372
 
353
- def _extract_first_user_message_preview(rollout_path: 'Path') -> 'typing.Union[str, None]':
373
+ def _extract_first_user_message_preview(
374
+ rollout_path: "Path",
375
+ ) -> "typing.Union[str, None]":
354
376
  for entry in _iter_rollout_entries(rollout_path):
355
377
  if entry.get("type") != "event_msg":
356
378
  continue
@@ -363,13 +385,51 @@ def _extract_first_user_message_preview(rollout_path: 'Path') -> 'typing.Union[s
363
385
  return None
364
386
 
365
387
 
366
- def _iter_rollout_entries(rollout_path: 'Path') -> 'typing.Iterable[typing.Dict[str, object]]':
388
+ def _find_last_compacted_offset(
389
+ rollout_path: "Path",
390
+ ) -> "typing.Union[int, None]":
391
+ """Find the latest recorder-format compact checkpoint."""
392
+
393
+ file_size = rollout_path.stat().st_size
394
+ if not file_size:
395
+ return None
396
+
397
+ with rollout_path.open("rb") as handle:
398
+ mapped = mmap.mmap(handle.fileno(), 0, access=mmap.ACCESS_READ)
399
+ try:
400
+ marker_offset = mapped.rfind(COMPACTED_RECORD_MARKER)
401
+ if marker_offset < 0:
402
+ return None
403
+ record_start = mapped.rfind(
404
+ ROLLOUT_RECORD_PREFIX,
405
+ 0,
406
+ marker_offset,
407
+ )
408
+ if record_start < 0:
409
+ return None
410
+ previous = record_start - 1
411
+ while previous >= 0 and mapped[previous] in b" \t\r\n":
412
+ previous -= 1
413
+ if previous >= 0 and mapped[previous] != ord("}"):
414
+ return None
415
+ return record_start + 1
416
+ finally:
417
+ mapped.close()
418
+ return None
419
+
420
+
421
+ def _iter_rollout_entries(
422
+ rollout_path: "Path",
423
+ start_offset: "int" = 0,
424
+ ) -> "typing.Iterable[typing.Dict[str, object]]":
367
425
  decoder = json.JSONDecoder()
368
426
  buffer = ""
369
427
  start = 0
370
428
  parsed_entries = 0
371
429
 
372
430
  with rollout_path.open("r", encoding="utf-8", errors="replace") as handle:
431
+ if start_offset:
432
+ handle.seek(start_offset)
373
433
  while True:
374
434
  chunk = handle.read(ROLLOUT_READ_CHUNK_SIZE)
375
435
  eof = not chunk
@@ -410,19 +470,33 @@ def _iter_rollout_entries(rollout_path: 'Path') -> 'typing.Iterable[typing.Dict[
410
470
  raise ValueError(f"no rollout entries found in {rollout_path}")
411
471
 
412
472
 
413
- def _extract_response_message_text(payload: 'typing.Dict[str, object]') -> 'str':
414
- text_parts: 'typing.List[str]' = []
473
+ def _extract_response_message_text(payload: "typing.Dict[str, object]") -> "str":
474
+ text_parts: "typing.List[str]" = []
415
475
  for item in payload.get("content") or []:
416
476
  if isinstance(item, dict) and item.get("type") in {"input_text", "output_text"}:
417
477
  text_parts.append(str(item.get("text", "")))
418
478
  return "".join(text_parts)
419
479
 
420
480
 
481
+ def _compacted_payload(
482
+ history: "typing.Iterable[ConversationItem]",
483
+ ) -> "typing.Dict[str, object]":
484
+ payload: "typing.Dict[str, object]" = {"replacement_history": []}
485
+ for item in history:
486
+ serialized = item.serialize()
487
+ if isinstance(serialized, dict):
488
+ payload["replacement_history"].append(serialized)
489
+ if isinstance(item, ContextMessage) and item.role == "user":
490
+ payload["message"] = item.text
491
+ return payload
492
+
493
+
421
494
  def _deserialize_compacted_history(
422
- replacement_history: 'typing.Iterable[object]',
423
- ) -> 'typing.List[ConversationItem]':
424
- history: 'typing.List[ConversationItem]' = []
425
- tool_names_by_call_id: 'typing.Dict[str, str]' = {}
495
+ replacement_history: "typing.Iterable[object]",
496
+ summary_message: "typing.Union[str, None]" = None,
497
+ ) -> "typing.List[ConversationItem]":
498
+ history: "typing.List[ConversationItem]" = []
499
+ tool_names_by_call_id: "typing.Dict[str, str]" = {}
426
500
  for payload in replacement_history:
427
501
  if not isinstance(payload, dict):
428
502
  continue
@@ -432,23 +506,35 @@ def _deserialize_compacted_history(
432
506
  tool_names_by_call_id,
433
507
  include_user_messages=True,
434
508
  )
509
+ for index in range(len(history) - 1, -1, -1):
510
+ item = history[index]
511
+ if isinstance(item, UserMessage) and (
512
+ item.text == summary_message
513
+ or (summary_message is None and item.text.startswith(SUMMARY_PREFIX + "\n"))
514
+ ):
515
+ history[index] = ContextMessage(text=item.text, id=item.id)
516
+ break
435
517
  return history
436
518
 
437
519
 
438
520
  def _append_deserialized_response_item(
439
- history: 'typing.List[ConversationItem]',
440
- payload: 'typing.Dict[str, object]',
441
- tool_names_by_call_id: 'typing.Dict[str, str]',
442
- include_user_messages: 'bool',
443
- ) -> 'None':
521
+ history: "typing.List[ConversationItem]",
522
+ payload: "typing.Dict[str, object]",
523
+ tool_names_by_call_id: "typing.Dict[str, str]",
524
+ include_user_messages: "bool",
525
+ ) -> "None":
444
526
  response_item_type = str(payload.get("type", "")).strip()
445
527
  if response_item_type == "message":
446
528
  role = str(payload.get("role", "")).strip()
447
529
  if role == "assistant":
448
- history.append(AssistantMessage(text=_extract_response_message_text(payload)))
530
+ history.append(AssistantMessage.from_response_item(payload))
449
531
  return
450
532
  if include_user_messages and role == "user":
451
- history.append(UserMessage(text=_extract_response_message_text(payload)))
533
+ history.append(
534
+ UserMessage(
535
+ text=_extract_response_message_text(payload), id=payload.get("id")
536
+ )
537
+ )
452
538
  return
453
539
 
454
540
  if response_item_type == "reasoning":
@@ -472,7 +558,16 @@ def _append_deserialized_response_item(
472
558
  name = str(payload.get("name", "")).strip()
473
559
  if not call_id or not name:
474
560
  return
475
- history.append(ToolCall(call_id=call_id, name=name, arguments=arguments))
561
+ history.append(
562
+ ToolCall(
563
+ call_id=call_id,
564
+ name=name,
565
+ arguments=arguments,
566
+ id=payload.get("id"),
567
+ raw_arguments=raw_arguments if isinstance(raw_arguments, str) else None,
568
+ namespace=payload.get("namespace"),
569
+ )
570
+ )
476
571
  tool_names_by_call_id[call_id] = name
477
572
  return
478
573
 
@@ -487,6 +582,9 @@ def _append_deserialized_response_item(
487
582
  name=name,
488
583
  arguments=str(payload.get("input", "")),
489
584
  tool_type="custom",
585
+ id=payload.get("id"),
586
+ namespace=payload.get("namespace"),
587
+ status=payload.get("status"),
490
588
  )
491
589
  )
492
590
  tool_names_by_call_id[call_id] = name
@@ -505,7 +603,10 @@ def _append_deserialized_response_item(
505
603
  ):
506
604
  content_items = tuple(dict(item) for item in raw_output)
507
605
  output = json.dumps(raw_output, ensure_ascii=False)
508
- elif isinstance(raw_output, (dict, list, str, int, float, bool)) or raw_output is None:
606
+ elif (
607
+ isinstance(raw_output, (dict, list, str, int, float, bool))
608
+ or raw_output is None
609
+ ):
509
610
  output = raw_output
510
611
  else:
511
612
  output = str(raw_output)
@@ -515,6 +616,7 @@ def _append_deserialized_response_item(
515
616
  name=tool_names_by_call_id.get(call_id, ""),
516
617
  output=output,
517
618
  content_items=content_items,
619
+ id=payload.get("id"),
518
620
  success=(
519
621
  payload.get("success")
520
622
  if isinstance(payload.get("success"), bool)
@@ -529,7 +631,7 @@ def _append_deserialized_response_item(
529
631
  )
530
632
 
531
633
 
532
- def _rollout_path_for_session(codex_home: 'Path', session_id: 'str') -> 'Path':
634
+ def rollout_path_for_session(codex_home: "Path", session_id: "str") -> "Path":
533
635
  now = datetime.now().astimezone()
534
636
  return (
535
637
  codex_home
@@ -541,5 +643,5 @@ def _rollout_path_for_session(codex_home: 'Path', session_id: 'str') -> 'Path':
541
643
  )
542
644
 
543
645
 
544
- def _timestamp_string() -> 'str':
646
+ def _timestamp_string() -> "str":
545
647
  return datetime.now().astimezone().isoformat(timespec="milliseconds")