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.
Files changed (64) hide show
  1. pycodex/__init__.py +141 -2
  2. pycodex/agent.py +290 -0
  3. pycodex/cli.py +705 -0
  4. pycodex/collaboration.py +21 -0
  5. pycodex/context.py +580 -0
  6. pycodex/doctor.py +360 -0
  7. pycodex/model.py +533 -0
  8. pycodex/portable.py +390 -0
  9. pycodex/portable_server.py +205 -0
  10. pycodex/prompts/collaboration_default.md +11 -0
  11. pycodex/prompts/collaboration_plan.md +128 -0
  12. pycodex/prompts/default_base_instructions.md +275 -0
  13. pycodex/prompts/exec_tools.json +411 -0
  14. pycodex/prompts/models.json +847 -0
  15. pycodex/prompts/permissions/approval_policy/never.md +1 -0
  16. pycodex/prompts/permissions/approval_policy/on_failure.md +1 -0
  17. pycodex/prompts/permissions/approval_policy/on_request.md +57 -0
  18. pycodex/prompts/permissions/approval_policy/on_request_rule_request_permission.md +33 -0
  19. pycodex/prompts/permissions/approval_policy/unless_trusted.md +1 -0
  20. pycodex/prompts/permissions/sandbox_mode/danger_full_access.md +1 -0
  21. pycodex/prompts/permissions/sandbox_mode/read_only.md +1 -0
  22. pycodex/prompts/permissions/sandbox_mode/workspace_write.md +1 -0
  23. pycodex/prompts/subagent_tools.json +163 -0
  24. pycodex/protocol.py +347 -0
  25. pycodex/runtime.py +204 -0
  26. pycodex/runtime_services.py +409 -0
  27. pycodex/tools/__init__.py +58 -0
  28. pycodex/tools/agent_tool_schemas.py +70 -0
  29. pycodex/tools/apply_patch_tool.py +363 -0
  30. pycodex/tools/base_tool.py +168 -0
  31. pycodex/tools/close_agent_tool.py +55 -0
  32. pycodex/tools/code_mode_manager.py +519 -0
  33. pycodex/tools/exec_command_tool.py +96 -0
  34. pycodex/tools/exec_runtime.js +161 -0
  35. pycodex/tools/exec_tool.py +48 -0
  36. pycodex/tools/grep_files_tool.py +150 -0
  37. pycodex/tools/list_dir_tool.py +135 -0
  38. pycodex/tools/read_file_tool.py +217 -0
  39. pycodex/tools/request_permissions_tool.py +95 -0
  40. pycodex/tools/request_user_input_tool.py +167 -0
  41. pycodex/tools/resume_agent_tool.py +56 -0
  42. pycodex/tools/send_input_tool.py +106 -0
  43. pycodex/tools/shell_command_tool.py +107 -0
  44. pycodex/tools/shell_tool.py +112 -0
  45. pycodex/tools/spawn_agent_tool.py +97 -0
  46. pycodex/tools/unified_exec_manager.py +380 -0
  47. pycodex/tools/update_plan_tool.py +79 -0
  48. pycodex/tools/view_image_tool.py +111 -0
  49. pycodex/tools/wait_agent_tool.py +75 -0
  50. pycodex/tools/wait_tool.py +68 -0
  51. pycodex/tools/web_search_tool.py +30 -0
  52. pycodex/tools/write_stdin_tool.py +75 -0
  53. pycodex/utils/__init__.py +40 -0
  54. pycodex/utils/dotenv.py +64 -0
  55. pycodex/utils/get_env.py +218 -0
  56. pycodex/utils/random_ids.py +19 -0
  57. pycodex/utils/visualize.py +978 -0
  58. python_codex-0.1.1.dist-info/METADATA +355 -0
  59. python_codex-0.1.1.dist-info/RECORD +62 -0
  60. python_codex-0.1.1.dist-info/entry_points.txt +2 -0
  61. python_codex-0.1.1.dist-info/licenses/LICENSE +201 -0
  62. python_codex-0.0.1.dist-info/METADATA +0 -30
  63. python_codex-0.0.1.dist-info/RECORD +0 -4
  64. {python_codex-0.0.1.dist-info → python_codex-0.1.1.dist-info}/WHEEL +0 -0
@@ -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