python-codex 0.2.7__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 +14 -14
  2. pycodex/agent.py +465 -499
  3. pycodex/bootstrap.py +417 -0
  4. pycodex/cli.py +236 -510
  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 +324 -253
  12. pycodex/model_metadata.py +19 -7
  13. pycodex/portable.py +76 -45
  14. pycodex/portable_server.py +32 -24
  15. pycodex/prompts/models.json +245 -983
  16. pycodex/protocol.py +177 -137
  17. pycodex/runtime.py +579 -176
  18. pycodex/runtime_services.py +204 -157
  19. pycodex/tools/__init__.py +1 -1
  20. pycodex/tools/apply_patch_tool.py +69 -48
  21. pycodex/tools/base_tool.py +89 -42
  22. pycodex/tools/clock_tool.py +58 -25
  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 +7 -5
  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 +41 -72
  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/{image_utils.py → utils/image_utils.py} +8 -11
  52. pycodex/utils/random_ids.py +1 -2
  53. pycodex/utils/session_persist.py +217 -163
  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 +23 -23
  61. responses_server/messages_api.py +51 -53
  62. responses_server/payload_processors.py +25 -20
  63. responses_server/server.py +11 -11
  64. responses_server/session_store.py +14 -11
  65. responses_server/stream_router.py +101 -98
  66. responses_server/tools/custom_adapter.py +17 -16
  67. responses_server/tools/web_search.py +39 -36
  68. responses_server/trajectory_dump.py +36 -14
  69. workspace_server/__main__.py +0 -1
  70. workspace_server/app.py +461 -375
  71. workspace_server/workspace.html +852 -228
  72. workspace_server/workspaces.html +94 -95
  73. workspace_server/workspaces.py +137 -79
  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 -560
  80. python_codex-0.2.7.dist-info/METADATA +0 -455
  81. python_codex-0.2.7.dist-info/RECORD +0 -93
  82. {python_codex-0.2.7.dist-info → python_codex-0.3.0.dist-info}/WHEEL +0 -0
  83. {python_codex-0.2.7.dist-info → python_codex-0.3.0.dist-info}/entry_points.txt +0 -0
  84. {python_codex-0.2.7.dist-info → python_codex-0.3.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,19 +1,21 @@
1
+ import typing
1
2
  from dataclasses import dataclass
2
3
 
3
4
  from ..protocol import (
4
5
  AssistantMessage,
6
+ ContextMessage,
5
7
  ConversationItem,
6
8
  ModelResponse,
7
- ModelStreamEvent,
8
9
  ToolCall,
9
10
  ToolResult,
10
11
  UserMessage,
11
12
  )
12
13
  from .random_ids import uuid7_string
13
- import typing
14
14
 
15
15
  if typing.TYPE_CHECKING:
16
- from ..agent import Agent
16
+ from ..context import ContextManager
17
+ from ..events import ModelEvent
18
+ from ..model import ModelClient
17
19
 
18
20
  DEFAULT_COMPACT_PROMPT = """You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.
19
21
 
@@ -36,33 +38,22 @@ SUMMARY_PREFIX = (
36
38
  "not merely repeat or acknowledge the handoff:"
37
39
  )
38
40
 
41
+
39
42
  @dataclass(frozen=True)
40
43
  class CompactResult:
41
- history: 'typing.Tuple[ConversationItem, ...]'
42
- original_item_count: 'int'
43
- pruned_tool_results: 'int' = 0
44
+ history: "typing.Tuple[ConversationItem, ...]"
45
+ original_item_count: "int"
46
+ pruned_tool_results: "int" = 0
44
47
 
45
48
  @property
46
- def retained_item_count(self) -> 'int':
49
+ def retained_item_count(self) -> "int":
47
50
  return max(len(self.history) - 1, 0)
48
51
 
49
- def display_text(self) -> 'str':
50
- retained_label = _pluralize("item", self.retained_item_count)
51
- original_label = _pluralize("item", self.original_item_count)
52
- text = (
53
- f"compact({self.original_item_count} {original_label}) -> "
54
- f"{self.retained_item_count} {retained_label} + [summary]"
55
- )
56
- if self.pruned_tool_results:
57
- tool_label = _pluralize("tool response", self.pruned_tool_results)
58
- text += f" (dropped {self.pruned_tool_results} old {tool_label})"
59
- return text
60
-
61
52
 
62
53
  def compact(
63
- history: 'typing.Sequence[ConversationItem]',
64
- session_file: 'typing.Union[str, None]' = None,
65
- ) -> 'typing.Tuple[ConversationItem, ...]':
54
+ history: "typing.Sequence[ConversationItem]",
55
+ session_file: "typing.Union[str, None]" = None,
56
+ ) -> "typing.Tuple[ConversationItem, ...]":
66
57
  summary_text = _build_summary_message(_last_assistant_message(history))
67
58
  if session_file:
68
59
  summary_text += (
@@ -75,30 +66,34 @@ def compact(
75
66
  return build_compacted_history(summary_text)
76
67
 
77
68
 
78
- async def compact_agent(
79
- agent: 'Agent',
80
- stream_event_handler: 'typing.Union[typing.Callable[[ModelStreamEvent], None], None]' = None,
81
- prune_tool_results_on_context_error: 'bool' = False,
82
- ) -> 'typing.Union[CompactResult, None]':
83
- from ..model import ResponsesIncompleteError
69
+ async def compact_history(
70
+ history: "typing.Sequence[ConversationItem]",
71
+ model_client: "ModelClient",
72
+ context_manager: "ContextManager",
73
+ stream_event_handler: "typing.Union[typing.Callable[[ModelEvent], None], None]" = None,
74
+ prune_tool_results_on_context_error: "bool" = False,
75
+ session_file: "typing.Union[str, None]" = None,
76
+ turn_id: "typing.Union[str, None]" = None,
77
+ ) -> "typing.Union[CompactResult, None]":
78
+ from ..model import ContextLengthExceeded, ResponsesIncompleteError
84
79
 
85
- history = agent.history
86
80
  if not history:
87
81
  return None
88
82
  original_item_count = len(history)
89
83
  pruned_tool_results = 0
84
+ turn_id = turn_id or uuid7_string()
90
85
 
91
86
  noop_stream_event_handler = lambda _event: None
92
87
  while True:
93
88
  compact_prompt = UserMessage(text=DEFAULT_COMPACT_PROMPT)
94
- prompt = agent._context_manager.build_prompt(
89
+ prompt = context_manager.build_prompt(
95
90
  list(history) + [compact_prompt],
96
91
  [],
97
92
  False,
98
- turn_id=uuid7_string(),
93
+ turn_id=turn_id,
99
94
  )
100
95
  try:
101
- response = await agent._model_client.complete(
96
+ response = await model_client.complete(
102
97
  prompt,
103
98
  stream_event_handler or noop_stream_event_handler,
104
99
  )
@@ -111,32 +106,22 @@ async def compact_agent(
111
106
  raise
112
107
  response = ModelResponse(items=list(exc.partial_items))
113
108
  break
114
- except Exception as exc:
115
- if (
116
- not prune_tool_results_on_context_error
117
- or not _is_context_length_error(str(exc))
118
- ):
109
+ except ContextLengthExceeded:
110
+ if not prune_tool_results_on_context_error:
119
111
  raise
120
112
  pruned_history = prune_oldest_tool_response(history)
121
113
  if pruned_history is None:
122
114
  raise
123
115
  history = pruned_history
124
116
  pruned_tool_results += 1
125
- agent.replace_history(history)
126
117
 
127
- rollout_recorder = agent._rollout_recorder
128
- session_file = (
129
- str(rollout_recorder.rollout_path)
130
- if rollout_recorder is not None
131
- else None
132
- )
118
+ summary = _last_assistant_message(response.items)
119
+ if summary is None or not summary.strip():
120
+ raise ValueError("compact response contains no assistant summary")
133
121
  compacted_history = compact(
134
- list(history) + [compact_prompt] + list(response.items),
122
+ response.items,
135
123
  session_file,
136
124
  )
137
- agent.replace_history(compacted_history)
138
- if rollout_recorder is not None:
139
- rollout_recorder.append_compacted_history(compacted_history)
140
125
  return CompactResult(
141
126
  history=compacted_history,
142
127
  original_item_count=original_item_count,
@@ -145,8 +130,8 @@ async def compact_agent(
145
130
 
146
131
 
147
132
  def prune_oldest_tool_response(
148
- history: 'typing.Sequence[ConversationItem]',
149
- ) -> 'typing.Union[typing.Tuple[ConversationItem, ...], None]':
133
+ history: "typing.Sequence[ConversationItem]",
134
+ ) -> "typing.Union[typing.Tuple[ConversationItem, ...], None]":
150
135
  items = list(history)
151
136
  tool_result_index = None
152
137
  call_id = None
@@ -170,36 +155,20 @@ def prune_oldest_tool_response(
170
155
 
171
156
 
172
157
  def build_compacted_history(
173
- summary_text: 'str',
174
- ) -> 'typing.Tuple[ConversationItem, ...]':
175
- return (UserMessage(text=summary_text or _build_summary_message(None)),)
158
+ summary_text: "str",
159
+ ) -> "typing.Tuple[ConversationItem, ...]":
160
+ return (ContextMessage(text=summary_text or _build_summary_message(None)),)
176
161
 
177
162
 
178
163
  def _last_assistant_message(
179
- history: 'typing.Sequence[ConversationItem]',
180
- ) -> 'typing.Union[str, None]':
164
+ history: "typing.Sequence[ConversationItem]",
165
+ ) -> "typing.Union[str, None]":
181
166
  for item in reversed(tuple(history)):
182
167
  if isinstance(item, AssistantMessage):
183
168
  return item.text
184
169
  return None
185
170
 
186
171
 
187
- def _build_summary_message(summary_text: 'typing.Union[str, None]') -> 'str':
172
+ def _build_summary_message(summary_text: "typing.Union[str, None]") -> "str":
188
173
  normalized = (summary_text or "").strip() or "(no summary available)"
189
174
  return f"{SUMMARY_PREFIX}\n{normalized}"
190
-
191
-
192
- def _pluralize(noun: 'str', count: 'int') -> 'str':
193
- if count == 1:
194
- return noun
195
- return f"{noun}s"
196
-
197
-
198
- def _is_context_length_error(message: 'str') -> 'bool':
199
- lower = message.lower()
200
- return (
201
- "context_length_exceeded" in lower
202
- or "maximum context length" in lower
203
- or "exceeds the context window" in lower
204
- or "exceeded the context window" in lower
205
- )
pycodex/utils/debug.py CHANGED
@@ -1,9 +1,9 @@
1
1
  import os
2
- from pathlib import Path
3
2
  import typing
3
+ from pathlib import Path
4
4
 
5
5
 
6
- def get_debug_dir() -> 'typing.Union[Path, None]':
6
+ def get_debug_dir() -> "typing.Union[Path, None]":
7
7
  value = os.environ.get("PYCODEX_DEBUG_LOG", "").strip()
8
8
  if not value:
9
9
  return None
pycodex/utils/dotenv.py CHANGED
@@ -1,14 +1,13 @@
1
-
2
1
  import os
3
- from pathlib import Path
4
2
  import typing
3
+ from pathlib import Path
5
4
 
6
5
  ILLEGAL_ENV_VAR_PREFIX = "CODEX_"
7
6
  DOTENV_FILENAME = ".env"
8
- _LOADED_CODEX_DOTENV_HOMES: 'typing.Set[str]' = set()
7
+ _LOADED_CODEX_DOTENV_HOMES: "typing.Set[str]" = set()
9
8
 
10
9
 
11
- def load_codex_dotenv(config_path: 'typing.Union[str, Path]') -> 'None':
10
+ def load_codex_dotenv(config_path: "typing.Union[str, Path]") -> "None":
12
11
  codex_home = str(Path(config_path).resolve().parent)
13
12
  if codex_home in _LOADED_CODEX_DOTENV_HOMES:
14
13
  return
@@ -28,8 +27,8 @@ def load_codex_dotenv(config_path: 'typing.Union[str, Path]') -> 'None':
28
27
  _LOADED_CODEX_DOTENV_HOMES.add(codex_home)
29
28
 
30
29
 
31
- def parse_dotenv(text: 'str') -> 'typing.Dict[str, str]':
32
- values: 'typing.Dict[str, str]' = {}
30
+ def parse_dotenv(text: "str") -> "typing.Dict[str, str]":
31
+ values: "typing.Dict[str, str]" = {}
33
32
  for raw_line in text.splitlines():
34
33
  line = raw_line.strip()
35
34
  if not line or line.startswith("#"):
@@ -47,7 +46,7 @@ def parse_dotenv(text: 'str') -> 'typing.Dict[str, str]':
47
46
  return values
48
47
 
49
48
 
50
- def parse_dotenv_value(raw_value: 'str') -> 'str':
49
+ def parse_dotenv_value(raw_value: "str") -> "str":
51
50
  if not raw_value:
52
51
  return ""
53
52
 
@@ -0,0 +1,190 @@
1
+ """Stateless presentation helpers; no event or frontend dependencies."""
2
+
3
+ import json
4
+ import typing
5
+ from dataclasses import asdict
6
+
7
+ PROMPT_CONTEXT_BASELINE_TOKENS = 12_000
8
+
9
+
10
+ def render_result(kind, result, json_mode, log):
11
+ if kind == "turn":
12
+ text = (
13
+ json.dumps(asdict(result), ensure_ascii=False, indent=2)
14
+ if json_mode
15
+ else (result.output_text or "")
16
+ )
17
+ log(text)
18
+ elif kind == "command":
19
+ for line in format_command_result(result).splitlines():
20
+ log(line)
21
+
22
+
23
+ def format_command_result(result: "typing.Dict[str, object]") -> "str":
24
+ kind = result["kind"]
25
+ if kind == "help":
26
+ return "Extra commands: " + ", ".join("/" + name for name in result["commands"])
27
+ if kind in {"title", "title_changed"}:
28
+ return "Session: " + (result["title"] or "untitled")
29
+ if kind == "models":
30
+ return (
31
+ "Current model: "
32
+ + result["model"]
33
+ + "\nAvailable models: "
34
+ + ", ".join(result["models"])
35
+ )
36
+ if kind == "model_changed":
37
+ return "Switched model to {0}.".format(result["model"])
38
+ if kind == "sessions":
39
+ if not result["sessions"]:
40
+ return "No resumable sessions found."
41
+ return "\n".join(
42
+ ["Available sessions:"]
43
+ + [
44
+ "[{0}] {1}".format(index, session["preview"])
45
+ for index, session in enumerate(result["sessions"], 1)
46
+ ]
47
+ )
48
+ if kind in {"history", "resumed"}:
49
+ state = result["state"]
50
+ lines = ["Resumed session: " + state["title"]] if kind == "resumed" else []
51
+ if not state["history"]:
52
+ return "\n".join(lines + ["No history yet."])
53
+ lines.append("Session: " + (state["title"] or "untitled"))
54
+ for index, (prompt, response) in enumerate(state["history"], 1):
55
+ if prompt:
56
+ lines.append("[{0}]U> {1}".format(index, prompt))
57
+ if response:
58
+ lines.append("[{0}]A> {1}".format(index, response))
59
+ return "\n".join(lines)
60
+ if kind == "compact_empty":
61
+ return "Nothing to compact."
62
+ if kind == "compacted":
63
+ return compact_summary(
64
+ result["original_item_count"],
65
+ result["retained_item_count"],
66
+ result["pruned_tool_results"],
67
+ )
68
+ if kind == "forked":
69
+ return "Forked session: " + result["session_id"]
70
+ if kind == "closed":
71
+ return ""
72
+ return "\n".join(result["lines"])
73
+
74
+
75
+ def format_error(text, indent=True):
76
+ if not indent:
77
+ return "Error: " + str(text)
78
+ lines = str(text).splitlines() or [""]
79
+ return "\n".join(
80
+ ["Error: " + lines[0]] + [" " + line if line else "" for line in lines[1:]]
81
+ )
82
+
83
+
84
+ def shorten_title(text: "str", limit: "int" = 48) -> "str":
85
+ return truncate_text(text, limit)
86
+
87
+
88
+ def percent_of_context_window_remaining(total_tokens, context_window_tokens):
89
+ if context_window_tokens <= PROMPT_CONTEXT_BASELINE_TOKENS:
90
+ return 0
91
+ effective_window = context_window_tokens - PROMPT_CONTEXT_BASELINE_TOKENS
92
+ used = max(total_tokens - PROMPT_CONTEXT_BASELINE_TOKENS, 0)
93
+ remaining = max(effective_window - used, 0)
94
+ return int(round(max(0.0, min(100.0, (remaining / effective_window) * 100.0))))
95
+
96
+
97
+ def completed_history(state):
98
+ active = state["active_turn"]
99
+ if active is not None:
100
+ return active["completed_history"]
101
+ return state["history"]
102
+
103
+
104
+ def colorize_cli_message(text: "str", kind: "str", enabled: "bool") -> "str":
105
+ if not enabled:
106
+ return text
107
+ if kind == "assistant":
108
+ color = "\x1b[32m"
109
+ elif kind == "status":
110
+ color = "\x1b[36m"
111
+ elif kind == "error":
112
+ color = "\x1b[31m"
113
+ else:
114
+ return text
115
+ return "\x1b[1m" + color + text + "\x1b[0m"
116
+
117
+
118
+ def colorize_tool_message(
119
+ message: "str", enabled: "bool", tool_name: "str" = None
120
+ ) -> "str":
121
+ if not enabled:
122
+ return message
123
+ if message.startswith("[error]"):
124
+ return colorize_cli_message(message, "error", enabled)
125
+ if message.startswith("[") and message.find("]") > 1:
126
+ tool_name = message[1 : message.find("]")]
127
+ if message.startswith((" [x]", " [>]", " [ ]")) or tool_name == "update_plan":
128
+ color = "\x1b[36m"
129
+ elif tool_name == "exec_command" and "session_id=" in message:
130
+ color = "\x1b[2m"
131
+ elif tool_name == "exec_command" and not message.startswith("["):
132
+ color = ""
133
+ elif tool_name in {"exec_command", "shell", "shell_command", "exec"}:
134
+ color = "\x1b[33m"
135
+ elif tool_name in {"write_stdin", "wait", "web_search"}:
136
+ color = "\x1b[35m"
137
+ elif tool_name in {
138
+ "spawn_agent",
139
+ "send_input",
140
+ "wait_agent",
141
+ "resume_agent",
142
+ "close_agent",
143
+ }:
144
+ color = "\x1b[34m"
145
+ else:
146
+ color = "\x1b[2m"
147
+ return "\x1b[1m" + color + message + "\x1b[0m"
148
+
149
+
150
+ def compact_summary(original: "int", retained: "int", pruned: "int") -> "str":
151
+ text = "compact({0} {1}) -> {2} {3} + [summary]".format(
152
+ original,
153
+ "item" if original == 1 else "items",
154
+ retained,
155
+ "item" if retained == 1 else "items",
156
+ )
157
+ if pruned:
158
+ text += " (dropped {0} old {1})".format(
159
+ pruned,
160
+ "tool response" if pruned == 1 else "tool responses",
161
+ )
162
+ return text
163
+
164
+
165
+ def agent_status_summary(status: "object") -> "str":
166
+ if isinstance(status, str):
167
+ return status
168
+ if isinstance(status, dict):
169
+ if "completed" in status:
170
+ completed = status.get("completed")
171
+ if completed is None:
172
+ return "completed"
173
+ return f"completed: {truncate_text(str(completed), 48)}"
174
+ if "errored" in status:
175
+ return f"errored: {truncate_text(str(status.get('errored', '')), 48)}"
176
+ return truncate_text(json.dumps(status, ensure_ascii=False, separators=(",", ":")))
177
+
178
+
179
+ def short_id(value: "str", limit: "int" = 8) -> "str":
180
+ compact = value.strip()
181
+ if len(compact) <= limit + 4:
182
+ return compact
183
+ return f"{compact[:limit]}...{compact[-4:]}"
184
+
185
+
186
+ def truncate_text(text: "str", limit: "int" = 96) -> "str":
187
+ compact = " ".join(text.split())
188
+ if len(compact) <= limit:
189
+ return compact
190
+ return compact[: limit - 3].rstrip() + "..."
pycodex/utils/get_env.py CHANGED
@@ -1,22 +1,19 @@
1
1
  import os
2
2
  import platform
3
- import re
4
- from datetime import datetime
5
- from pathlib import Path
6
3
  import subprocess
7
4
  import typing
8
-
9
- from ..compat import importlib_metadata
5
+ from datetime import datetime
6
+ from pathlib import Path
10
7
 
11
8
 
12
- def get_shell_name() -> 'str':
9
+ def get_shell_name() -> "str":
13
10
  shell_path = os.environ.get("SHELL")
14
11
  if shell_path:
15
12
  return Path(shell_path).name or shell_path
16
13
  return "bash"
17
14
 
18
15
 
19
- def get_timezone_name() -> 'str':
16
+ def get_timezone_name() -> "str":
20
17
  timezone_env = os.environ.get("TZ")
21
18
  if timezone_env:
22
19
  return timezone_env
@@ -33,7 +30,9 @@ def get_timezone_name() -> 'str':
33
30
  return "Etc/UTC"
34
31
  name = str(timezone)
35
32
  return name or "Etc/UTC"
36
- def get_sandbox_tag(sandbox_mode: 'typing.Union[str, None]') -> 'str':
33
+
34
+
35
+ def get_sandbox_tag(sandbox_mode: "typing.Union[str, None]") -> "str":
37
36
  if sandbox_mode == "danger-full-access":
38
37
  return "none"
39
38
  if sandbox_mode == "read-only":
@@ -43,7 +42,9 @@ def get_sandbox_tag(sandbox_mode: 'typing.Union[str, None]') -> 'str':
43
42
  return "none"
44
43
 
45
44
 
46
- def get_workspace_turn_metadata(cwd: 'typing.Union[str, Path]') -> 'typing.Union[typing.Dict[str, object], None]':
45
+ def get_workspace_turn_metadata(
46
+ cwd: "typing.Union[str, Path]",
47
+ ) -> "typing.Union[typing.Dict[str, object], None]":
47
48
  resolved_cwd = Path(cwd).resolve()
48
49
  repo_root = _git_output(
49
50
  resolved_cwd,
@@ -52,7 +53,7 @@ def get_workspace_turn_metadata(cwd: 'typing.Union[str, Path]') -> 'typing.Union
52
53
  if repo_root is None:
53
54
  return None
54
55
 
55
- workspace: 'typing.Dict[str, object]' = {}
56
+ workspace: "typing.Dict[str, object]" = {}
56
57
  head = _git_output(resolved_cwd, ["rev-parse", "HEAD"])
57
58
  if head is not None:
58
59
  workspace["latest_git_commit_hash"] = head
@@ -70,7 +71,7 @@ def get_workspace_turn_metadata(cwd: 'typing.Union[str, Path]') -> 'typing.Union
70
71
  return {"workspaces": {repo_root: workspace}}
71
72
 
72
73
 
73
- def build_user_agent(originator: 'str') -> 'str':
74
+ def build_user_agent(originator: "str") -> "str":
74
75
  version = get_package_version()
75
76
  terminal = get_terminal_user_agent_token()
76
77
  os_name, os_version = get_os_info()
@@ -79,25 +80,15 @@ def build_user_agent(originator: 'str') -> 'str':
79
80
  return f"{originator}/{version} ({os_name} {os_version}; {arch}) {terminal}{suffix}"
80
81
 
81
82
 
82
- def get_package_version() -> 'str':
83
- detected = _detect_upstream_codex_version()
84
- if detected is not None:
85
- return detected
86
- for distribution_name in ("python-codex", "pycodex"):
87
- try:
88
- return importlib_metadata.version(distribution_name)
89
- except importlib_metadata.PackageNotFoundError:
90
- continue
91
- local_version = _read_local_package_version()
92
- if local_version is not None:
93
- return local_version
94
- return "0.1.0"
83
+ def get_package_version() -> "str":
84
+ """Return the upstream alignment version recorded in docs/ALIGNMENT.md."""
85
+ return "0.153.4"
95
86
 
96
87
 
97
- def get_os_info() -> 'typing.Tuple[str, str]':
88
+ def get_os_info() -> "typing.Tuple[str, str]":
98
89
  os_release = Path("/etc/os-release")
99
90
  if os_release.is_file():
100
- values: 'typing.Dict[str, str]' = {}
91
+ values: "typing.Dict[str, str]" = {}
101
92
  os_release_text = os_release.read_text(
102
93
  encoding="utf-8",
103
94
  errors="replace",
@@ -114,7 +105,7 @@ def get_os_info() -> 'typing.Tuple[str, str]':
114
105
  return platform.system(), platform.release()
115
106
 
116
107
 
117
- def get_terminal_user_agent_token() -> 'str':
108
+ def get_terminal_user_agent_token() -> "str":
118
109
  term_program = os.environ.get("TERM_PROGRAM", "")
119
110
  if term_program.lower() == "tmux":
120
111
  client_termname = _tmux_display_message("#{client_termname}")
@@ -127,7 +118,7 @@ def get_terminal_user_agent_token() -> 'str':
127
118
  return "unknown"
128
119
 
129
120
 
130
- def _git_output(cwd: 'Path', args: 'typing.List[str]') -> 'typing.Union[str, None]':
121
+ def _git_output(cwd: "Path", args: "typing.List[str]") -> "typing.Union[str, None]":
131
122
  try:
132
123
  completed = subprocess.run(
133
124
  ["git", *args],
@@ -143,11 +134,11 @@ def _git_output(cwd: 'Path', args: 'typing.List[str]') -> 'typing.Union[str, Non
143
134
  return value or None
144
135
 
145
136
 
146
- def _git_remote_urls(cwd: 'Path') -> 'typing.Dict[str, str]':
137
+ def _git_remote_urls(cwd: "Path") -> "typing.Dict[str, str]":
147
138
  remote_names = _git_output(cwd, ["remote"])
148
139
  if remote_names is None:
149
140
  return {}
150
- remotes: 'typing.Dict[str, str]' = {}
141
+ remotes: "typing.Dict[str, str]" = {}
151
142
  for name in remote_names.splitlines():
152
143
  remote_name = name.strip()
153
144
  if not remote_name:
@@ -158,7 +149,7 @@ def _git_remote_urls(cwd: 'Path') -> 'typing.Dict[str, str]':
158
149
  return remotes
159
150
 
160
151
 
161
- def _git_has_changes(cwd: 'Path') -> 'typing.Union[bool, None]':
152
+ def _git_has_changes(cwd: "Path") -> "typing.Union[bool, None]":
162
153
  try:
163
154
  completed = subprocess.run(
164
155
  ["git", "status", "--porcelain"],
@@ -173,7 +164,7 @@ def _git_has_changes(cwd: 'Path') -> 'typing.Union[bool, None]':
173
164
  return bool(completed.stdout.strip())
174
165
 
175
166
 
176
- def _user_agent_suffix(originator: 'str', version: 'str') -> 'str':
167
+ def _user_agent_suffix(originator: "str", version: "str") -> "str":
177
168
  if originator == "codex_exec":
178
169
  return f" (codex-exec; {version})"
179
170
  if originator == "codex-tui":
@@ -181,7 +172,7 @@ def _user_agent_suffix(originator: 'str', version: 'str') -> 'str':
181
172
  return ""
182
173
 
183
174
 
184
- def _normalize_os_version(version: 'str') -> 'str':
175
+ def _normalize_os_version(version: "str") -> "str":
185
176
  parts = version.split(".")
186
177
  if len(parts) == 2 and all(part.isdigit() for part in parts):
187
178
  major, minor = parts
@@ -189,21 +180,7 @@ def _normalize_os_version(version: 'str') -> 'str':
189
180
  return version
190
181
 
191
182
 
192
- def _read_local_package_version() -> 'typing.Union[str, None]':
193
- pyproject_path = Path(__file__).resolve().parents[2] / "pyproject.toml"
194
- if not pyproject_path.is_file():
195
- return None
196
- match = re.search(
197
- r'^\s*version\s*=\s*"([^"]+)"\s*$',
198
- pyproject_path.read_text(encoding="utf-8"),
199
- flags=re.MULTILINE,
200
- )
201
- if match is None:
202
- return None
203
- return match.group(1).strip() or None
204
-
205
-
206
- def _tmux_display_message(fmt: 'str') -> 'typing.Union[str, None]':
183
+ def _tmux_display_message(fmt: "str") -> "typing.Union[str, None]":
207
184
  try:
208
185
  output = subprocess.run(
209
186
  ["tmux", "display-message", "-p", fmt],
@@ -218,28 +195,8 @@ def _tmux_display_message(fmt: 'str') -> 'typing.Union[str, None]':
218
195
  return value or None
219
196
 
220
197
 
221
- def _sanitize_header_token(value: 'str') -> 'str':
198
+ def _sanitize_header_token(value: "str") -> "str":
222
199
  return "".join(
223
- character
224
- if (character.isalnum() or character in {"-", "_", ".", "/"})
225
- else "_"
200
+ character if (character.isalnum() or character in {"-", "_", ".", "/"}) else "_"
226
201
  for character in value
227
202
  )
228
-
229
-
230
- def _detect_upstream_codex_version() -> 'typing.Union[str, None]':
231
- try:
232
- output = subprocess.run(
233
- ["codex", "--version"],
234
- check=True,
235
- stdout=subprocess.PIPE,
236
- stderr=subprocess.PIPE,
237
- universal_newlines=True,
238
- )
239
- except (OSError, subprocess.CalledProcessError):
240
- return None
241
-
242
- match = re.search(r"\b(\d+\.\d+\.\d+)\b", output.stdout)
243
- if match is None:
244
- return None
245
- return match.group(1)