python-codex 0.0.1__py3-none-any.whl → 0.1.1__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.
- pycodex/__init__.py +141 -2
- pycodex/agent.py +290 -0
- pycodex/cli.py +705 -0
- pycodex/collaboration.py +21 -0
- pycodex/context.py +580 -0
- pycodex/doctor.py +360 -0
- pycodex/model.py +533 -0
- pycodex/portable.py +390 -0
- pycodex/portable_server.py +205 -0
- pycodex/prompts/collaboration_default.md +11 -0
- pycodex/prompts/collaboration_plan.md +128 -0
- pycodex/prompts/default_base_instructions.md +275 -0
- pycodex/prompts/exec_tools.json +411 -0
- pycodex/prompts/models.json +847 -0
- pycodex/prompts/permissions/approval_policy/never.md +1 -0
- pycodex/prompts/permissions/approval_policy/on_failure.md +1 -0
- pycodex/prompts/permissions/approval_policy/on_request.md +57 -0
- pycodex/prompts/permissions/approval_policy/on_request_rule_request_permission.md +33 -0
- pycodex/prompts/permissions/approval_policy/unless_trusted.md +1 -0
- pycodex/prompts/permissions/sandbox_mode/danger_full_access.md +1 -0
- pycodex/prompts/permissions/sandbox_mode/read_only.md +1 -0
- pycodex/prompts/permissions/sandbox_mode/workspace_write.md +1 -0
- pycodex/prompts/subagent_tools.json +163 -0
- pycodex/protocol.py +347 -0
- pycodex/runtime.py +204 -0
- pycodex/runtime_services.py +409 -0
- pycodex/tools/__init__.py +58 -0
- pycodex/tools/agent_tool_schemas.py +70 -0
- pycodex/tools/apply_patch_tool.py +363 -0
- pycodex/tools/base_tool.py +168 -0
- pycodex/tools/close_agent_tool.py +55 -0
- pycodex/tools/code_mode_manager.py +519 -0
- pycodex/tools/exec_command_tool.py +96 -0
- pycodex/tools/exec_runtime.js +161 -0
- pycodex/tools/exec_tool.py +48 -0
- pycodex/tools/grep_files_tool.py +150 -0
- pycodex/tools/list_dir_tool.py +135 -0
- pycodex/tools/read_file_tool.py +217 -0
- pycodex/tools/request_permissions_tool.py +95 -0
- pycodex/tools/request_user_input_tool.py +167 -0
- pycodex/tools/resume_agent_tool.py +56 -0
- pycodex/tools/send_input_tool.py +106 -0
- pycodex/tools/shell_command_tool.py +107 -0
- pycodex/tools/shell_tool.py +112 -0
- pycodex/tools/spawn_agent_tool.py +97 -0
- pycodex/tools/unified_exec_manager.py +380 -0
- pycodex/tools/update_plan_tool.py +79 -0
- pycodex/tools/view_image_tool.py +111 -0
- pycodex/tools/wait_agent_tool.py +75 -0
- pycodex/tools/wait_tool.py +68 -0
- pycodex/tools/web_search_tool.py +30 -0
- pycodex/tools/write_stdin_tool.py +75 -0
- pycodex/utils/__init__.py +40 -0
- pycodex/utils/dotenv.py +64 -0
- pycodex/utils/get_env.py +218 -0
- pycodex/utils/random_ids.py +19 -0
- pycodex/utils/visualize.py +978 -0
- python_codex-0.1.1.dist-info/METADATA +355 -0
- python_codex-0.1.1.dist-info/RECORD +62 -0
- python_codex-0.1.1.dist-info/entry_points.txt +2 -0
- python_codex-0.1.1.dist-info/licenses/LICENSE +201 -0
- python_codex-0.0.1.dist-info/METADATA +0 -30
- python_codex-0.0.1.dist-info/RECORD +0 -4
- {python_codex-0.0.1.dist-info → python_codex-0.1.1.dist-info}/WHEEL +0 -0
pycodex/collaboration.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
CollaborationMode = Literal["default", "plan", "execute", "pair_programming"]
|
|
6
|
+
|
|
7
|
+
DEFAULT_COLLABORATION_MODE: CollaborationMode = "default"
|
|
8
|
+
PLAN_COLLABORATION_MODE: CollaborationMode = "plan"
|
|
9
|
+
|
|
10
|
+
_MODE_DISPLAY_NAMES: dict[str, str] = {
|
|
11
|
+
"default": "Default",
|
|
12
|
+
"plan": "Plan",
|
|
13
|
+
"execute": "Execute",
|
|
14
|
+
"pair_programming": "Pair Programming",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def collaboration_mode_display_name(mode: str | None) -> str:
|
|
19
|
+
normalized = (mode or DEFAULT_COLLABORATION_MODE).strip().lower()
|
|
20
|
+
return _MODE_DISPLAY_NAMES.get(normalized, normalized.replace("_", " ").title())
|
|
21
|
+
|
pycodex/context.py
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from functools import lru_cache
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import tomllib
|
|
11
|
+
except ModuleNotFoundError: # pragma: no cover - Python 3.10 path
|
|
12
|
+
import tomli as tomllib
|
|
13
|
+
|
|
14
|
+
from .collaboration import DEFAULT_COLLABORATION_MODE, CollaborationMode
|
|
15
|
+
from .protocol import ContextMessage, ConversationItem, JSONDict, Prompt, ToolSpec
|
|
16
|
+
from .utils.get_env import (
|
|
17
|
+
get_sandbox_tag,
|
|
18
|
+
get_shell_name,
|
|
19
|
+
get_timezone_name,
|
|
20
|
+
get_workspace_turn_metadata,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
DEFAULT_BASE_INSTRUCTIONS_PATH = (
|
|
24
|
+
Path(__file__).resolve().parent / "prompts" / "default_base_instructions.md"
|
|
25
|
+
)
|
|
26
|
+
DEFAULT_MODELS_PATH = Path(__file__).resolve().parent / "prompts" / "models.json"
|
|
27
|
+
DEFAULT_COLLABORATION_INSTRUCTIONS_PATH = (
|
|
28
|
+
Path(__file__).resolve().parent / "prompts" / "collaboration_default.md"
|
|
29
|
+
)
|
|
30
|
+
PLAN_COLLABORATION_INSTRUCTIONS_PATH = (
|
|
31
|
+
Path(__file__).resolve().parent / "prompts" / "collaboration_plan.md"
|
|
32
|
+
)
|
|
33
|
+
PERMISSIONS_SANDBOX_PROMPTS_PATH = (
|
|
34
|
+
Path(__file__).resolve().parent / "prompts" / "permissions" / "sandbox_mode"
|
|
35
|
+
)
|
|
36
|
+
PERMISSIONS_APPROVAL_PROMPTS_PATH = (
|
|
37
|
+
Path(__file__).resolve().parent / "prompts" / "permissions" / "approval_policy"
|
|
38
|
+
)
|
|
39
|
+
PROJECT_DOC_SEPARATOR = "\n\n--- project-doc ---\n\n"
|
|
40
|
+
DEFAULT_PROJECT_DOC_FILENAME = "AGENTS.md"
|
|
41
|
+
LOCAL_PROJECT_DOC_FILENAME = "AGENTS.override.md"
|
|
42
|
+
USER_INSTRUCTIONS_PREFIX = "# AGENTS.md instructions for "
|
|
43
|
+
PERMISSIONS_OPEN_TAG = "<permissions instructions>"
|
|
44
|
+
PERMISSIONS_CLOSE_TAG = "</permissions instructions>"
|
|
45
|
+
SKILLS_OPEN_TAG = "<skills_instructions>"
|
|
46
|
+
SKILLS_CLOSE_TAG = "</skills_instructions>"
|
|
47
|
+
COLLABORATION_MODE_OPEN_TAG = "<collaboration_mode>"
|
|
48
|
+
COLLABORATION_MODE_CLOSE_TAG = "</collaboration_mode>"
|
|
49
|
+
PERSONALITY_PLACEHOLDER = "{{ personality }}"
|
|
50
|
+
SKILLS_GUIDANCE = """- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.
|
|
51
|
+
- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
|
|
52
|
+
- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.
|
|
53
|
+
- How to use a skill (progressive disclosure):
|
|
54
|
+
1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.
|
|
55
|
+
2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the skill directory listed above first, and only consider other paths if needed.
|
|
56
|
+
3) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.
|
|
57
|
+
4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.
|
|
58
|
+
5) If `assets/` or templates exist, reuse them instead of recreating from scratch.
|
|
59
|
+
- Coordination and sequencing:
|
|
60
|
+
- If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.
|
|
61
|
+
- Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.
|
|
62
|
+
- Context hygiene:
|
|
63
|
+
- Keep context small: summarize long sections instead of pasting them; only load extra files when needed.
|
|
64
|
+
- Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.
|
|
65
|
+
- When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.
|
|
66
|
+
- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class ContextConfig:
|
|
71
|
+
base_instructions: str | None = None
|
|
72
|
+
developer_instructions: str | None = None
|
|
73
|
+
user_instructions: str | None = None
|
|
74
|
+
codex_home_instructions: str | None = None
|
|
75
|
+
model_instructions_file: Path | None = None
|
|
76
|
+
codex_home: Path | None = None
|
|
77
|
+
project_doc_max_bytes: int | None = None
|
|
78
|
+
model: str | None = None
|
|
79
|
+
personality: str | None = None
|
|
80
|
+
approval_policy: str | None = None
|
|
81
|
+
sandbox_mode: str | None = None
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_codex_config(
|
|
85
|
+
cls,
|
|
86
|
+
config_path: str | Path,
|
|
87
|
+
profile: str | None = None,
|
|
88
|
+
) -> ContextConfig:
|
|
89
|
+
path = Path(config_path)
|
|
90
|
+
data = tomllib.loads(path.read_text())
|
|
91
|
+
selected = dict(data)
|
|
92
|
+
if profile is not None:
|
|
93
|
+
overrides = data.get("profiles", {}).get(profile)
|
|
94
|
+
if overrides is None:
|
|
95
|
+
raise ValueError(f"unknown Codex profile: {profile}")
|
|
96
|
+
selected.update(overrides)
|
|
97
|
+
|
|
98
|
+
model_instructions_file = selected.get("model_instructions_file")
|
|
99
|
+
resolved_file = None
|
|
100
|
+
if model_instructions_file:
|
|
101
|
+
candidate = Path(str(model_instructions_file))
|
|
102
|
+
if not candidate.is_absolute():
|
|
103
|
+
candidate = path.parent / candidate
|
|
104
|
+
resolved_file = candidate.resolve()
|
|
105
|
+
|
|
106
|
+
codex_home = path.parent.resolve()
|
|
107
|
+
codex_home_instructions = _read_first_instruction_file(codex_home)
|
|
108
|
+
|
|
109
|
+
return cls(
|
|
110
|
+
base_instructions=_normalize_text(selected.get("base_instructions")),
|
|
111
|
+
developer_instructions=_normalize_text(
|
|
112
|
+
selected.get("developer_instructions")
|
|
113
|
+
),
|
|
114
|
+
user_instructions=_normalize_text(selected.get("user_instructions")),
|
|
115
|
+
codex_home_instructions=codex_home_instructions,
|
|
116
|
+
model_instructions_file=resolved_file,
|
|
117
|
+
codex_home=codex_home,
|
|
118
|
+
project_doc_max_bytes=_normalize_int(selected.get("project_doc_max_bytes")),
|
|
119
|
+
model=_normalize_text(selected.get("model")),
|
|
120
|
+
personality=_normalize_text(selected.get("personality")),
|
|
121
|
+
approval_policy=_normalize_text(selected.get("approval_policy")),
|
|
122
|
+
sandbox_mode=_normalize_text(selected.get("sandbox_mode")),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True, slots=True)
|
|
127
|
+
class SkillDescriptor:
|
|
128
|
+
name: str
|
|
129
|
+
description: str
|
|
130
|
+
path_to_skill_md: Path
|
|
131
|
+
scope_rank: int
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class ContextManager:
|
|
135
|
+
def __init__(
|
|
136
|
+
self,
|
|
137
|
+
base_instructions_override: str | None = None,
|
|
138
|
+
config: ContextConfig | None = None,
|
|
139
|
+
collaboration_mode: CollaborationMode = DEFAULT_COLLABORATION_MODE,
|
|
140
|
+
collaboration_instructions: str | None = None,
|
|
141
|
+
include_collaboration_instructions: bool = False,
|
|
142
|
+
include_permissions_instructions: bool = True,
|
|
143
|
+
include_skills_instructions: bool = True,
|
|
144
|
+
network_access: str = "enabled",
|
|
145
|
+
) -> None:
|
|
146
|
+
self.cwd = Path.cwd().resolve()
|
|
147
|
+
self._shell = get_shell_name()
|
|
148
|
+
self._current_date = datetime.now().date().isoformat()
|
|
149
|
+
self._timezone_name = get_timezone_name()
|
|
150
|
+
self._base_instructions_override = _normalize_text(base_instructions_override)
|
|
151
|
+
self._config = config or ContextConfig()
|
|
152
|
+
self._collaboration_mode = collaboration_mode
|
|
153
|
+
self._collaboration_instructions = (
|
|
154
|
+
collaboration_instructions
|
|
155
|
+
if collaboration_instructions is not None
|
|
156
|
+
else _default_collaboration_instructions(collaboration_mode)
|
|
157
|
+
)
|
|
158
|
+
self._include_collaboration_instructions = include_collaboration_instructions
|
|
159
|
+
self._include_permissions_instructions = include_permissions_instructions
|
|
160
|
+
self._include_skills_instructions = include_skills_instructions
|
|
161
|
+
self._network_access = network_access
|
|
162
|
+
self._default_base_instructions = DEFAULT_BASE_INSTRUCTIONS_PATH.read_text()
|
|
163
|
+
self._workspace_metadata_turn_id: str | None = None
|
|
164
|
+
self._workspace_metadata_cache: JSONDict | None = None
|
|
165
|
+
|
|
166
|
+
@classmethod
|
|
167
|
+
def from_codex_config(
|
|
168
|
+
cls,
|
|
169
|
+
config_path: str | Path,
|
|
170
|
+
profile: str | None = None,
|
|
171
|
+
base_instructions_override: str | None = None,
|
|
172
|
+
collaboration_mode: CollaborationMode = DEFAULT_COLLABORATION_MODE,
|
|
173
|
+
include_collaboration_instructions: bool = False,
|
|
174
|
+
include_permissions_instructions: bool = True,
|
|
175
|
+
include_skills_instructions: bool = True,
|
|
176
|
+
network_access: str = "enabled",
|
|
177
|
+
) -> ContextManager:
|
|
178
|
+
config = ContextConfig.from_codex_config(config_path, profile)
|
|
179
|
+
return cls(
|
|
180
|
+
base_instructions_override=base_instructions_override,
|
|
181
|
+
config=config,
|
|
182
|
+
collaboration_mode=collaboration_mode,
|
|
183
|
+
include_collaboration_instructions=include_collaboration_instructions,
|
|
184
|
+
include_permissions_instructions=include_permissions_instructions,
|
|
185
|
+
include_skills_instructions=include_skills_instructions,
|
|
186
|
+
network_access=network_access,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def collaboration_mode(self) -> CollaborationMode:
|
|
191
|
+
return self._collaboration_mode
|
|
192
|
+
|
|
193
|
+
def get_turn_metadata(self, turn_id: str) -> JSONDict:
|
|
194
|
+
metadata: JSONDict = {"turn_id": turn_id}
|
|
195
|
+
if self._workspace_metadata_turn_id is None:
|
|
196
|
+
self._workspace_metadata_turn_id = turn_id
|
|
197
|
+
self._workspace_metadata_cache = get_workspace_turn_metadata(self.cwd)
|
|
198
|
+
if (
|
|
199
|
+
turn_id == self._workspace_metadata_turn_id
|
|
200
|
+
and self._workspace_metadata_cache is not None
|
|
201
|
+
):
|
|
202
|
+
metadata.update(self._workspace_metadata_cache)
|
|
203
|
+
metadata["sandbox"] = get_sandbox_tag(self._config.sandbox_mode)
|
|
204
|
+
return metadata
|
|
205
|
+
|
|
206
|
+
def build_prompt(
|
|
207
|
+
self,
|
|
208
|
+
history: tuple[ConversationItem, ...] | list[ConversationItem],
|
|
209
|
+
tools: list[ToolSpec],
|
|
210
|
+
parallel_tool_calls: bool,
|
|
211
|
+
turn_id: str | None = None,
|
|
212
|
+
) -> Prompt:
|
|
213
|
+
input_items: list[ConversationItem] = []
|
|
214
|
+
turn_metadata = self.get_turn_metadata(turn_id) if turn_id is not None else None
|
|
215
|
+
|
|
216
|
+
developer_message = self._build_developer_message()
|
|
217
|
+
if developer_message is not None:
|
|
218
|
+
input_items.append(developer_message)
|
|
219
|
+
|
|
220
|
+
input_items.extend(self._build_contextual_user_messages())
|
|
221
|
+
input_items.extend(list(history))
|
|
222
|
+
return Prompt(
|
|
223
|
+
input=input_items,
|
|
224
|
+
tools=tools,
|
|
225
|
+
parallel_tool_calls=parallel_tool_calls,
|
|
226
|
+
base_instructions=self.resolve_base_instructions(),
|
|
227
|
+
turn_id=turn_id,
|
|
228
|
+
turn_metadata=turn_metadata,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def resolve_base_instructions(self) -> str:
|
|
232
|
+
if self._base_instructions_override is not None:
|
|
233
|
+
return self._base_instructions_override
|
|
234
|
+
if self._config.base_instructions is not None:
|
|
235
|
+
return self._config.base_instructions
|
|
236
|
+
if self._config.model_instructions_file is not None:
|
|
237
|
+
return self._config.model_instructions_file.read_text().strip()
|
|
238
|
+
resolved = self._resolve_model_instructions()
|
|
239
|
+
if resolved is not None:
|
|
240
|
+
return resolved
|
|
241
|
+
return self._default_base_instructions
|
|
242
|
+
|
|
243
|
+
def _resolve_model_instructions(self) -> str | None:
|
|
244
|
+
model_slug = self._config.model
|
|
245
|
+
if model_slug is None:
|
|
246
|
+
return None
|
|
247
|
+
model_metadata = _load_models_by_slug().get(model_slug)
|
|
248
|
+
if model_metadata is None:
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
model_messages = model_metadata.get("model_messages")
|
|
252
|
+
if isinstance(model_messages, dict):
|
|
253
|
+
template = model_messages.get("instructions_template")
|
|
254
|
+
variables = model_messages.get("instructions_variables")
|
|
255
|
+
if isinstance(template, str):
|
|
256
|
+
personality_message = _resolve_personality_message(
|
|
257
|
+
variables,
|
|
258
|
+
self._config.personality,
|
|
259
|
+
)
|
|
260
|
+
return template.replace(PERSONALITY_PLACEHOLDER, personality_message)
|
|
261
|
+
|
|
262
|
+
base_instructions = model_metadata.get("base_instructions")
|
|
263
|
+
if isinstance(base_instructions, str):
|
|
264
|
+
return base_instructions
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
def _build_developer_message(self) -> ContextMessage | None:
|
|
268
|
+
sections: list[str] = []
|
|
269
|
+
if self._include_permissions_instructions:
|
|
270
|
+
permissions = self._build_permissions_instructions()
|
|
271
|
+
if permissions is not None:
|
|
272
|
+
sections.append(permissions)
|
|
273
|
+
if self._config.developer_instructions is not None:
|
|
274
|
+
sections.append(self._config.developer_instructions)
|
|
275
|
+
if self._include_collaboration_instructions:
|
|
276
|
+
collaboration = self._collaboration_instructions.strip()
|
|
277
|
+
if collaboration:
|
|
278
|
+
sections.append(
|
|
279
|
+
f"{COLLABORATION_MODE_OPEN_TAG}{collaboration}"
|
|
280
|
+
f"\n{COLLABORATION_MODE_CLOSE_TAG}"
|
|
281
|
+
)
|
|
282
|
+
if self._include_skills_instructions:
|
|
283
|
+
skills = self._build_skills_instructions()
|
|
284
|
+
if skills is not None:
|
|
285
|
+
sections.append(skills)
|
|
286
|
+
if not sections:
|
|
287
|
+
return None
|
|
288
|
+
return ContextMessage(
|
|
289
|
+
role="developer",
|
|
290
|
+
content_items=tuple(_input_text_item(section) for section in sections),
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def _build_permissions_instructions(self) -> str | None:
|
|
294
|
+
sandbox_mode = self._config.sandbox_mode or "danger-full-access"
|
|
295
|
+
approval_policy = self._config.approval_policy or "never"
|
|
296
|
+
sandbox_prompt_name = sandbox_mode.replace("-", "_")
|
|
297
|
+
sandbox_prompt_path = (
|
|
298
|
+
PERMISSIONS_SANDBOX_PROMPTS_PATH / f"{sandbox_prompt_name}.md"
|
|
299
|
+
)
|
|
300
|
+
approval_prompt_path = (
|
|
301
|
+
PERMISSIONS_APPROVAL_PROMPTS_PATH / f"{approval_policy.replace('-', '_')}.md"
|
|
302
|
+
)
|
|
303
|
+
if not sandbox_prompt_path.exists() or not approval_prompt_path.exists():
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
sandbox_text = (
|
|
307
|
+
sandbox_prompt_path.read_text().strip().replace(
|
|
308
|
+
"{network_access}", self._network_access
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
approval_text = approval_prompt_path.read_text().strip()
|
|
312
|
+
return "\n".join(
|
|
313
|
+
[
|
|
314
|
+
PERMISSIONS_OPEN_TAG,
|
|
315
|
+
sandbox_text,
|
|
316
|
+
approval_text,
|
|
317
|
+
PERMISSIONS_CLOSE_TAG,
|
|
318
|
+
]
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
def _build_skills_instructions(self) -> str | None:
|
|
322
|
+
skills = self._discover_skills()
|
|
323
|
+
if not skills:
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
lines = [
|
|
327
|
+
"## Skills",
|
|
328
|
+
"A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.",
|
|
329
|
+
"### Available skills",
|
|
330
|
+
]
|
|
331
|
+
for skill in skills:
|
|
332
|
+
path_str = skill.path_to_skill_md.as_posix()
|
|
333
|
+
lines.append(
|
|
334
|
+
f"- {skill.name}: {skill.description} (file: {path_str})"
|
|
335
|
+
)
|
|
336
|
+
lines.append("### How to use skills")
|
|
337
|
+
lines.extend(SKILLS_GUIDANCE.splitlines())
|
|
338
|
+
body = "\n".join(lines)
|
|
339
|
+
return f"{SKILLS_OPEN_TAG}\n{body}\n{SKILLS_CLOSE_TAG}"
|
|
340
|
+
|
|
341
|
+
def _discover_skills(self) -> list[SkillDescriptor]:
|
|
342
|
+
codex_home = self._config.codex_home
|
|
343
|
+
if codex_home is None:
|
|
344
|
+
return []
|
|
345
|
+
|
|
346
|
+
user_root = codex_home / "skills"
|
|
347
|
+
system_root = user_root / ".system"
|
|
348
|
+
discovered: list[SkillDescriptor] = []
|
|
349
|
+
seen: set[Path] = set()
|
|
350
|
+
|
|
351
|
+
user_paths = _discover_skill_files(user_root, excluded_root=system_root)
|
|
352
|
+
system_paths = _discover_skill_files(system_root)
|
|
353
|
+
|
|
354
|
+
for scope_rank, paths in ((0, user_paths), (1, system_paths)):
|
|
355
|
+
for path in paths:
|
|
356
|
+
resolved = path.resolve()
|
|
357
|
+
if resolved in seen:
|
|
358
|
+
continue
|
|
359
|
+
seen.add(resolved)
|
|
360
|
+
descriptor = _parse_skill_descriptor(path, scope_rank)
|
|
361
|
+
if descriptor is not None:
|
|
362
|
+
discovered.append(descriptor)
|
|
363
|
+
|
|
364
|
+
return sorted(
|
|
365
|
+
discovered,
|
|
366
|
+
key=lambda skill: (skill.scope_rank, skill.name, skill.path_to_skill_md),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
def _build_contextual_user_messages(self) -> list[ContextMessage]:
|
|
370
|
+
sections: list[str] = []
|
|
371
|
+
user_instructions = self._merged_user_instructions()
|
|
372
|
+
if user_instructions is not None:
|
|
373
|
+
sections.append(
|
|
374
|
+
(
|
|
375
|
+
f"{USER_INSTRUCTIONS_PREFIX}{self.cwd}\n\n"
|
|
376
|
+
f"<INSTRUCTIONS>\n{user_instructions}\n</INSTRUCTIONS>"
|
|
377
|
+
)
|
|
378
|
+
)
|
|
379
|
+
sections.append(self._serialize_environment_context())
|
|
380
|
+
if not sections:
|
|
381
|
+
return []
|
|
382
|
+
return [
|
|
383
|
+
ContextMessage(
|
|
384
|
+
role="user",
|
|
385
|
+
content_items=tuple(_input_text_item(section) for section in sections),
|
|
386
|
+
)
|
|
387
|
+
]
|
|
388
|
+
|
|
389
|
+
def _merged_user_instructions(self) -> str | None:
|
|
390
|
+
parts: list[str] = []
|
|
391
|
+
if self._config.user_instructions is not None:
|
|
392
|
+
parts.append(self._config.user_instructions)
|
|
393
|
+
if self._config.codex_home_instructions is not None:
|
|
394
|
+
parts.append(self._config.codex_home_instructions)
|
|
395
|
+
|
|
396
|
+
project_doc = self._read_project_docs()
|
|
397
|
+
if project_doc is not None:
|
|
398
|
+
prefix = "\n\n".join(parts)
|
|
399
|
+
if prefix:
|
|
400
|
+
return f"{prefix}{PROJECT_DOC_SEPARATOR}{project_doc}"
|
|
401
|
+
return project_doc
|
|
402
|
+
|
|
403
|
+
return "\n\n".join(parts) or None
|
|
404
|
+
|
|
405
|
+
def _read_project_docs(self) -> str | None:
|
|
406
|
+
docs: list[str] = []
|
|
407
|
+
remaining = self._config.project_doc_max_bytes
|
|
408
|
+
for path in self._discover_project_doc_paths():
|
|
409
|
+
text = path.read_text()
|
|
410
|
+
if not text.strip():
|
|
411
|
+
continue
|
|
412
|
+
if remaining is None:
|
|
413
|
+
docs.append(text)
|
|
414
|
+
continue
|
|
415
|
+
if remaining <= 0:
|
|
416
|
+
break
|
|
417
|
+
encoded = text.encode()
|
|
418
|
+
docs.append(encoded[:remaining].decode(errors="ignore"))
|
|
419
|
+
remaining -= min(len(encoded), remaining)
|
|
420
|
+
if not docs:
|
|
421
|
+
return None
|
|
422
|
+
return "\n\n".join(docs)
|
|
423
|
+
|
|
424
|
+
def _discover_project_doc_paths(self) -> list[Path]:
|
|
425
|
+
seen: set[Path] = set()
|
|
426
|
+
discovered: list[Path] = []
|
|
427
|
+
|
|
428
|
+
search_dirs = self._project_search_dirs()
|
|
429
|
+
for directory in search_dirs:
|
|
430
|
+
for candidate_name in (LOCAL_PROJECT_DOC_FILENAME, DEFAULT_PROJECT_DOC_FILENAME):
|
|
431
|
+
candidate = (directory / candidate_name).resolve()
|
|
432
|
+
if candidate.exists() and candidate.is_file() and candidate not in seen:
|
|
433
|
+
discovered.append(candidate)
|
|
434
|
+
seen.add(candidate)
|
|
435
|
+
break
|
|
436
|
+
return discovered
|
|
437
|
+
|
|
438
|
+
def _project_search_dirs(self) -> list[Path]:
|
|
439
|
+
project_root = self._find_project_root()
|
|
440
|
+
directories: list[Path] = []
|
|
441
|
+
current = self.cwd
|
|
442
|
+
chain = [current]
|
|
443
|
+
while current != project_root and current.parent != current:
|
|
444
|
+
current = current.parent
|
|
445
|
+
chain.append(current)
|
|
446
|
+
chain.reverse()
|
|
447
|
+
directories.extend(chain)
|
|
448
|
+
return directories
|
|
449
|
+
|
|
450
|
+
def _find_project_root(self) -> Path:
|
|
451
|
+
for ancestor in [self.cwd, *self.cwd.parents]:
|
|
452
|
+
if (ancestor / ".git").exists():
|
|
453
|
+
return ancestor
|
|
454
|
+
return self.cwd
|
|
455
|
+
|
|
456
|
+
def _serialize_environment_context(self) -> str:
|
|
457
|
+
lines = [
|
|
458
|
+
"<environment_context>",
|
|
459
|
+
f" <cwd>{self.cwd}</cwd>",
|
|
460
|
+
f" <shell>{self._shell}</shell>",
|
|
461
|
+
f" <current_date>{self._current_date}</current_date>",
|
|
462
|
+
f" <timezone>{self._timezone_name}</timezone>",
|
|
463
|
+
"</environment_context>",
|
|
464
|
+
]
|
|
465
|
+
return "\n".join(lines)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _input_text_item(text: str) -> JSONDict:
|
|
469
|
+
return {"type": "input_text", "text": text}
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _normalize_text(value) -> str | None:
|
|
473
|
+
if value is None:
|
|
474
|
+
return None
|
|
475
|
+
text = str(value).strip()
|
|
476
|
+
return text or None
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _normalize_int(value) -> int | None:
|
|
480
|
+
if value is None:
|
|
481
|
+
return None
|
|
482
|
+
return int(value)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _default_collaboration_instructions(mode: CollaborationMode) -> str:
|
|
486
|
+
if mode == "plan":
|
|
487
|
+
return PLAN_COLLABORATION_INSTRUCTIONS_PATH.read_text()
|
|
488
|
+
return DEFAULT_COLLABORATION_INSTRUCTIONS_PATH.read_text()
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _read_first_instruction_file(base: Path) -> str | None:
|
|
492
|
+
for candidate_name in (LOCAL_PROJECT_DOC_FILENAME, DEFAULT_PROJECT_DOC_FILENAME):
|
|
493
|
+
candidate = base / candidate_name
|
|
494
|
+
try:
|
|
495
|
+
contents = candidate.read_text()
|
|
496
|
+
except OSError:
|
|
497
|
+
continue
|
|
498
|
+
trimmed = contents.strip()
|
|
499
|
+
if trimmed:
|
|
500
|
+
return trimmed
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
@lru_cache(maxsize=1)
|
|
505
|
+
def _load_models_by_slug() -> dict[str, JSONDict]:
|
|
506
|
+
payload = json.loads(DEFAULT_MODELS_PATH.read_text())
|
|
507
|
+
models = payload.get("models", [])
|
|
508
|
+
by_slug: dict[str, JSONDict] = {}
|
|
509
|
+
for model in models:
|
|
510
|
+
slug = model.get("slug")
|
|
511
|
+
if isinstance(slug, str):
|
|
512
|
+
by_slug[slug] = model
|
|
513
|
+
return by_slug
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _resolve_personality_message(variables, personality: str | None) -> str:
|
|
517
|
+
if not isinstance(variables, dict):
|
|
518
|
+
return ""
|
|
519
|
+
normalized = (personality or "").strip().lower()
|
|
520
|
+
if normalized == "friendly":
|
|
521
|
+
key = "personality_friendly"
|
|
522
|
+
elif normalized == "pragmatic":
|
|
523
|
+
key = "personality_pragmatic"
|
|
524
|
+
elif normalized == "none":
|
|
525
|
+
return ""
|
|
526
|
+
else:
|
|
527
|
+
key = "personality_default"
|
|
528
|
+
value = variables.get(key)
|
|
529
|
+
if isinstance(value, str):
|
|
530
|
+
return value
|
|
531
|
+
return ""
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _discover_skill_files(
|
|
535
|
+
root: Path,
|
|
536
|
+
excluded_root: Path | None = None,
|
|
537
|
+
) -> list[Path]:
|
|
538
|
+
if not root.exists() or not root.is_dir():
|
|
539
|
+
return []
|
|
540
|
+
excluded = excluded_root.resolve() if excluded_root is not None and excluded_root.exists() else None
|
|
541
|
+
paths: list[Path] = []
|
|
542
|
+
for path in root.glob("**/SKILL.md"):
|
|
543
|
+
resolved = path.resolve()
|
|
544
|
+
if excluded is not None and (resolved == excluded or excluded in resolved.parents):
|
|
545
|
+
continue
|
|
546
|
+
paths.append(path)
|
|
547
|
+
return sorted(paths)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _parse_skill_descriptor(path: Path, scope_rank: int) -> SkillDescriptor | None:
|
|
551
|
+
text = path.read_text()
|
|
552
|
+
if not text.startswith("---\n"):
|
|
553
|
+
return None
|
|
554
|
+
end_marker = "\n---\n"
|
|
555
|
+
end_index = text.find(end_marker, 4)
|
|
556
|
+
if end_index == -1:
|
|
557
|
+
return None
|
|
558
|
+
frontmatter = text[4:end_index]
|
|
559
|
+
fields: dict[str, str] = {}
|
|
560
|
+
for line in frontmatter.splitlines():
|
|
561
|
+
if ":" not in line:
|
|
562
|
+
continue
|
|
563
|
+
key, _, raw_value = line.partition(":")
|
|
564
|
+
fields[key.strip()] = _strip_yaml_string(raw_value.strip())
|
|
565
|
+
name = fields.get("name")
|
|
566
|
+
description = fields.get("description")
|
|
567
|
+
if not name or not description:
|
|
568
|
+
return None
|
|
569
|
+
return SkillDescriptor(
|
|
570
|
+
name=name,
|
|
571
|
+
description=description,
|
|
572
|
+
path_to_skill_md=path.resolve(),
|
|
573
|
+
scope_rank=scope_rank,
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _strip_yaml_string(value: str) -> str:
|
|
578
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
|
|
579
|
+
return value[1:-1]
|
|
580
|
+
return value
|