python-codex 0.1.2__py3-none-any.whl → 0.1.4__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 (60) hide show
  1. pycodex/__init__.py +5 -1
  2. pycodex/agent.py +89 -51
  3. pycodex/cli.py +152 -45
  4. pycodex/collaboration.py +6 -7
  5. pycodex/compat.py +99 -0
  6. pycodex/context.py +110 -87
  7. pycodex/doctor.py +40 -40
  8. pycodex/model.py +429 -90
  9. pycodex/portable.py +33 -33
  10. pycodex/portable_server.py +22 -21
  11. pycodex/prompts/models.json +30 -0
  12. pycodex/protocol.py +84 -86
  13. pycodex/runtime.py +36 -35
  14. pycodex/runtime_services.py +69 -69
  15. pycodex/tools/agent_tool_schemas.py +0 -2
  16. pycodex/tools/apply_patch_tool.py +45 -46
  17. pycodex/tools/base_tool.py +35 -36
  18. pycodex/tools/close_agent_tool.py +2 -4
  19. pycodex/tools/code_mode_manager.py +61 -61
  20. pycodex/tools/exec_command_tool.py +5 -6
  21. pycodex/tools/exec_runtime.js +3 -3
  22. pycodex/tools/exec_tool.py +2 -4
  23. pycodex/tools/grep_files_tool.py +10 -11
  24. pycodex/tools/list_dir_tool.py +8 -9
  25. pycodex/tools/read_file_tool.py +13 -14
  26. pycodex/tools/request_permissions_tool.py +2 -4
  27. pycodex/tools/request_user_input_tool.py +13 -14
  28. pycodex/tools/resume_agent_tool.py +2 -4
  29. pycodex/tools/send_input_tool.py +8 -9
  30. pycodex/tools/shell_command_tool.py +5 -6
  31. pycodex/tools/shell_tool.py +5 -6
  32. pycodex/tools/spawn_agent_tool.py +4 -5
  33. pycodex/tools/unified_exec_manager.py +62 -61
  34. pycodex/tools/update_plan_tool.py +4 -5
  35. pycodex/tools/view_image_tool.py +4 -5
  36. pycodex/tools/wait_agent_tool.py +2 -4
  37. pycodex/tools/wait_tool.py +4 -5
  38. pycodex/tools/web_search_tool.py +1 -3
  39. pycodex/tools/write_stdin_tool.py +4 -5
  40. pycodex/utils/__init__.py +4 -0
  41. pycodex/utils/compactor.py +189 -0
  42. pycodex/utils/dotenv.py +6 -6
  43. pycodex/utils/get_env.py +37 -33
  44. pycodex/utils/random_ids.py +1 -2
  45. pycodex/utils/session_persist.py +483 -0
  46. pycodex/utils/visualize.py +197 -83
  47. {python_codex-0.1.2.dist-info → python_codex-0.1.4.dist-info}/METADATA +32 -11
  48. python_codex-0.1.4.dist-info/RECORD +76 -0
  49. {python_codex-0.1.2.dist-info → python_codex-0.1.4.dist-info}/WHEEL +1 -1
  50. responses_server/app.py +32 -20
  51. responses_server/config.py +17 -17
  52. responses_server/payload_processors.py +26 -17
  53. responses_server/server.py +11 -11
  54. responses_server/session_store.py +10 -10
  55. responses_server/stream_router.py +83 -64
  56. responses_server/tools/custom_adapter.py +12 -12
  57. responses_server/tools/web_search.py +33 -33
  58. python_codex-0.1.2.dist-info/RECORD +0 -73
  59. {python_codex-0.1.2.dist-info → python_codex-0.1.4.dist-info}/entry_points.txt +0 -0
  60. {python_codex-0.1.2.dist-info → python_codex-0.1.4.dist-info}/licenses/LICENSE +0 -0
pycodex/compat.py ADDED
@@ -0,0 +1,99 @@
1
+ import asyncio
2
+ import functools
3
+ import shlex
4
+
5
+ try:
6
+ from http.server import ThreadingHTTPServer
7
+ except ImportError: # pragma: no cover - Python 3.6 path
8
+ from http.server import HTTPServer
9
+ from socketserver import ThreadingMixIn
10
+
11
+ class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
12
+ daemon_threads = True
13
+
14
+ try:
15
+ from importlib import metadata as importlib_metadata
16
+ except ImportError: # pragma: no cover - Python 3.6 path
17
+ import importlib_metadata # type: ignore
18
+
19
+ try:
20
+ from typing import Literal, Protocol, TypeAlias
21
+ except ImportError: # pragma: no cover - Python 3.6 path
22
+ from typing_extensions import Literal, Protocol # type: ignore
23
+ try:
24
+ from typing_extensions import TypeAlias # type: ignore
25
+ except ImportError: # pragma: no cover - old typing_extensions
26
+ TypeAlias = object
27
+
28
+
29
+ def patch_asyncio():
30
+ if not hasattr(asyncio, "create_task"):
31
+ asyncio.create_task = asyncio.ensure_future
32
+
33
+ if not hasattr(asyncio, "get_running_loop"):
34
+ def get_running_loop():
35
+ return asyncio.get_event_loop()
36
+
37
+ asyncio.get_running_loop = get_running_loop
38
+
39
+ if not hasattr(asyncio, "to_thread"):
40
+ async def to_thread(func, *args, **kwargs):
41
+ loop = asyncio.get_event_loop()
42
+ call = functools.partial(func, *args, **kwargs)
43
+ return await loop.run_in_executor(None, call)
44
+
45
+ asyncio.to_thread = to_thread
46
+
47
+ if not hasattr(asyncio, "run"):
48
+ def run(main):
49
+ loop = asyncio.new_event_loop()
50
+ try:
51
+ asyncio.set_event_loop(loop)
52
+ return loop.run_until_complete(main)
53
+ finally:
54
+ all_tasks = getattr(asyncio.Task, "all_tasks", None)
55
+ if all_tasks is not None:
56
+ pending = all_tasks(loop=loop)
57
+ else:
58
+ pending = asyncio.all_tasks(loop)
59
+ for task in pending:
60
+ task.cancel()
61
+ if pending:
62
+ loop.run_until_complete(
63
+ asyncio.gather(*pending, return_exceptions=True)
64
+ )
65
+ shutdown_asyncgens = getattr(loop, "shutdown_asyncgens", None)
66
+ if shutdown_asyncgens is not None:
67
+ loop.run_until_complete(shutdown_asyncgens())
68
+ asyncio.set_event_loop(None)
69
+ loop.close()
70
+
71
+ asyncio.run = run
72
+
73
+
74
+ def shlex_join(parts):
75
+ join = getattr(shlex, "join", None)
76
+ if join is not None:
77
+ return join(parts)
78
+ return " ".join(shlex.quote(part) for part in parts)
79
+
80
+
81
+ def stream_writer_is_closing(writer):
82
+ method = getattr(writer, "is_closing", None)
83
+ if callable(method):
84
+ return method()
85
+ transport = getattr(writer, "transport", None)
86
+ if transport is None:
87
+ return False
88
+ transport_is_closing = getattr(transport, "is_closing", None)
89
+ if callable(transport_is_closing):
90
+ return transport_is_closing()
91
+ return False
92
+
93
+
94
+ def is_ascii(text):
95
+ try:
96
+ text.encode("ascii")
97
+ except UnicodeEncodeError:
98
+ return False
99
+ return True
pycodex/context.py CHANGED
@@ -1,10 +1,10 @@
1
- from __future__ import annotations
2
1
 
3
2
  from dataclasses import dataclass
4
3
  from datetime import datetime
5
4
  from functools import lru_cache
6
5
  import json
7
6
  from pathlib import Path
7
+ import typing
8
8
 
9
9
  try:
10
10
  import tomllib
@@ -30,6 +30,7 @@ DEFAULT_COLLABORATION_INSTRUCTIONS_PATH = (
30
30
  PLAN_COLLABORATION_INSTRUCTIONS_PATH = (
31
31
  Path(__file__).resolve().parent / "prompts" / "collaboration_plan.md"
32
32
  )
33
+ DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT = 95
33
34
  PERMISSIONS_SANDBOX_PROMPTS_PATH = (
34
35
  Path(__file__).resolve().parent / "prompts" / "permissions" / "sandbox_mode"
35
36
  )
@@ -66,26 +67,27 @@ SKILLS_GUIDANCE = """- Discovery: The list above is the skills available in this
66
67
  - 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
 
68
69
 
69
- @dataclass(frozen=True, slots=True)
70
+ @dataclass(frozen=True, )
70
71
  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
72
+ base_instructions: 'typing.Union[str, None]' = None
73
+ developer_instructions: 'typing.Union[str, None]' = None
74
+ user_instructions: 'typing.Union[str, None]' = None
75
+ codex_home_instructions: 'typing.Union[str, None]' = None
76
+ model_instructions_file: 'typing.Union[Path, None]' = None
77
+ codex_home: 'typing.Union[Path, None]' = None
78
+ project_doc_max_bytes: 'typing.Union[int, None]' = None
79
+ model: 'typing.Union[str, None]' = None
80
+ model_context_window: 'typing.Union[int, None]' = None
81
+ personality: 'typing.Union[str, None]' = None
82
+ approval_policy: 'typing.Union[str, None]' = None
83
+ sandbox_mode: 'typing.Union[str, None]' = None
82
84
 
83
85
  @classmethod
84
86
  def from_codex_config(
85
87
  cls,
86
- config_path: str | Path,
87
- profile: str | None = None,
88
- ) -> ContextConfig:
88
+ config_path: 'typing.Union[str, Path]',
89
+ profile: 'typing.Union[str, None]' = None,
90
+ ) -> 'ContextConfig':
89
91
  path = Path(config_path)
90
92
  data = tomllib.loads(path.read_text())
91
93
  selected = dict(data)
@@ -117,32 +119,33 @@ class ContextConfig:
117
119
  codex_home=codex_home,
118
120
  project_doc_max_bytes=_normalize_int(selected.get("project_doc_max_bytes")),
119
121
  model=_normalize_text(selected.get("model")),
122
+ model_context_window=_normalize_int(selected.get("model_context_window")),
120
123
  personality=_normalize_text(selected.get("personality")),
121
124
  approval_policy=_normalize_text(selected.get("approval_policy")),
122
125
  sandbox_mode=_normalize_text(selected.get("sandbox_mode")),
123
126
  )
124
127
 
125
128
 
126
- @dataclass(frozen=True, slots=True)
129
+ @dataclass(frozen=True, )
127
130
  class SkillDescriptor:
128
- name: str
129
- description: str
130
- path_to_skill_md: Path
131
- scope_rank: int
131
+ name: 'str'
132
+ description: 'str'
133
+ path_to_skill_md: 'Path'
134
+ scope_rank: 'int'
132
135
 
133
136
 
134
137
  class ContextManager:
135
138
  def __init__(
136
139
  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:
140
+ base_instructions_override: 'typing.Union[str, None]' = None,
141
+ config: 'typing.Union[ContextConfig, None]' = None,
142
+ collaboration_mode: 'CollaborationMode' = DEFAULT_COLLABORATION_MODE,
143
+ collaboration_instructions: 'typing.Union[str, None]' = None,
144
+ include_collaboration_instructions: 'bool' = False,
145
+ include_permissions_instructions: 'bool' = True,
146
+ include_skills_instructions: 'bool' = True,
147
+ network_access: 'str' = "enabled",
148
+ ) -> 'None':
146
149
  self.cwd = Path.cwd().resolve()
147
150
  self._shell = get_shell_name()
148
151
  self._current_date = datetime.now().date().isoformat()
@@ -160,21 +163,21 @@ class ContextManager:
160
163
  self._include_skills_instructions = include_skills_instructions
161
164
  self._network_access = network_access
162
165
  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
166
+ self._workspace_metadata_turn_id: 'typing.Union[str, None]' = None
167
+ self._workspace_metadata_cache: 'typing.Union[JSONDict, None]' = None
165
168
 
166
169
  @classmethod
167
170
  def from_codex_config(
168
171
  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:
172
+ config_path: 'typing.Union[str, Path]',
173
+ profile: 'typing.Union[str, None]' = None,
174
+ base_instructions_override: 'typing.Union[str, None]' = None,
175
+ collaboration_mode: 'CollaborationMode' = DEFAULT_COLLABORATION_MODE,
176
+ include_collaboration_instructions: 'bool' = False,
177
+ include_permissions_instructions: 'bool' = True,
178
+ include_skills_instructions: 'bool' = True,
179
+ network_access: 'str' = "enabled",
180
+ ) -> 'ContextManager':
178
181
  config = ContextConfig.from_codex_config(config_path, profile)
179
182
  return cls(
180
183
  base_instructions_override=base_instructions_override,
@@ -187,11 +190,11 @@ class ContextManager:
187
190
  )
188
191
 
189
192
  @property
190
- def collaboration_mode(self) -> CollaborationMode:
193
+ def collaboration_mode(self) -> 'CollaborationMode':
191
194
  return self._collaboration_mode
192
195
 
193
- def get_turn_metadata(self, turn_id: str) -> JSONDict:
194
- metadata: JSONDict = {"turn_id": turn_id}
196
+ def get_turn_metadata(self, turn_id: 'str') -> 'JSONDict':
197
+ metadata: 'JSONDict' = {"turn_id": turn_id}
195
198
  if self._workspace_metadata_turn_id is None:
196
199
  self._workspace_metadata_turn_id = turn_id
197
200
  self._workspace_metadata_cache = get_workspace_turn_metadata(self.cwd)
@@ -205,12 +208,12 @@ class ContextManager:
205
208
 
206
209
  def build_prompt(
207
210
  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] = []
211
+ history: 'typing.Union[typing.Tuple[ConversationItem, ...], typing.List[ConversationItem]]',
212
+ tools: 'typing.List[ToolSpec]',
213
+ parallel_tool_calls: 'bool',
214
+ turn_id: 'typing.Union[str, None]' = None,
215
+ ) -> 'Prompt':
216
+ input_items: 'typing.List[ConversationItem]' = []
214
217
  turn_metadata = self.get_turn_metadata(turn_id) if turn_id is not None else None
215
218
 
216
219
  developer_message = self._build_developer_message()
@@ -228,7 +231,7 @@ class ContextManager:
228
231
  turn_metadata=turn_metadata,
229
232
  )
230
233
 
231
- def resolve_base_instructions(self) -> str:
234
+ def resolve_base_instructions(self) -> 'str':
232
235
  if self._base_instructions_override is not None:
233
236
  return self._base_instructions_override
234
237
  if self._config.base_instructions is not None:
@@ -240,7 +243,27 @@ class ContextManager:
240
243
  return resolved
241
244
  return self._default_base_instructions
242
245
 
243
- def _resolve_model_instructions(self) -> str | None:
246
+ def resolve_model_context_window(self) -> 'typing.Union[int, None]':
247
+ model_metadata = None
248
+ model_slug = self._config.model
249
+ if model_slug is not None:
250
+ model_metadata = _load_models_by_slug().get(model_slug)
251
+
252
+ context_window = self._config.model_context_window
253
+ if context_window is None and model_metadata is not None:
254
+ context_window = _normalize_int(model_metadata.get("context_window"))
255
+ if context_window is None:
256
+ return None
257
+ effective_percent = None
258
+ if model_metadata is not None:
259
+ effective_percent = _normalize_int(
260
+ model_metadata.get("effective_context_window_percent")
261
+ )
262
+ if effective_percent is None:
263
+ effective_percent = DEFAULT_EFFECTIVE_CONTEXT_WINDOW_PERCENT
264
+ return context_window * max(effective_percent, 0) // 100
265
+
266
+ def _resolve_model_instructions(self) -> 'typing.Union[str, None]':
244
267
  model_slug = self._config.model
245
268
  if model_slug is None:
246
269
  return None
@@ -264,8 +287,8 @@ class ContextManager:
264
287
  return base_instructions
265
288
  return None
266
289
 
267
- def _build_developer_message(self) -> ContextMessage | None:
268
- sections: list[str] = []
290
+ def _build_developer_message(self) -> 'typing.Union[ContextMessage, None]':
291
+ sections: 'typing.List[str]' = []
269
292
  if self._include_permissions_instructions:
270
293
  permissions = self._build_permissions_instructions()
271
294
  if permissions is not None:
@@ -290,7 +313,7 @@ class ContextManager:
290
313
  content_items=tuple(_input_text_item(section) for section in sections),
291
314
  )
292
315
 
293
- def _build_permissions_instructions(self) -> str | None:
316
+ def _build_permissions_instructions(self) -> 'typing.Union[str, None]':
294
317
  sandbox_mode = self._config.sandbox_mode or "danger-full-access"
295
318
  approval_policy = self._config.approval_policy or "never"
296
319
  sandbox_prompt_name = sandbox_mode.replace("-", "_")
@@ -318,7 +341,7 @@ class ContextManager:
318
341
  ]
319
342
  )
320
343
 
321
- def _build_skills_instructions(self) -> str | None:
344
+ def _build_skills_instructions(self) -> 'typing.Union[str, None]':
322
345
  skills = self._discover_skills()
323
346
  if not skills:
324
347
  return None
@@ -338,15 +361,15 @@ class ContextManager:
338
361
  body = "\n".join(lines)
339
362
  return f"{SKILLS_OPEN_TAG}\n{body}\n{SKILLS_CLOSE_TAG}"
340
363
 
341
- def _discover_skills(self) -> list[SkillDescriptor]:
364
+ def _discover_skills(self) -> 'typing.List[SkillDescriptor]':
342
365
  codex_home = self._config.codex_home
343
366
  if codex_home is None:
344
367
  return []
345
368
 
346
369
  user_root = codex_home / "skills"
347
370
  system_root = user_root / ".system"
348
- discovered: list[SkillDescriptor] = []
349
- seen: set[Path] = set()
371
+ discovered: 'typing.List[SkillDescriptor]' = []
372
+ seen: 'typing.Set[Path]' = set()
350
373
 
351
374
  user_paths = _discover_skill_files(user_root, excluded_root=system_root)
352
375
  system_paths = _discover_skill_files(system_root)
@@ -366,8 +389,8 @@ class ContextManager:
366
389
  key=lambda skill: (skill.scope_rank, skill.name, skill.path_to_skill_md),
367
390
  )
368
391
 
369
- def _build_contextual_user_messages(self) -> list[ContextMessage]:
370
- sections: list[str] = []
392
+ def _build_contextual_user_messages(self) -> 'typing.List[ContextMessage]':
393
+ sections: 'typing.List[str]' = []
371
394
  user_instructions = self._merged_user_instructions()
372
395
  if user_instructions is not None:
373
396
  sections.append(
@@ -386,8 +409,8 @@ class ContextManager:
386
409
  )
387
410
  ]
388
411
 
389
- def _merged_user_instructions(self) -> str | None:
390
- parts: list[str] = []
412
+ def _merged_user_instructions(self) -> 'typing.Union[str, None]':
413
+ parts: 'typing.List[str]' = []
391
414
  if self._config.user_instructions is not None:
392
415
  parts.append(self._config.user_instructions)
393
416
  if self._config.codex_home_instructions is not None:
@@ -402,8 +425,8 @@ class ContextManager:
402
425
 
403
426
  return "\n\n".join(parts) or None
404
427
 
405
- def _read_project_docs(self) -> str | None:
406
- docs: list[str] = []
428
+ def _read_project_docs(self) -> 'typing.Union[str, None]':
429
+ docs: 'typing.List[str]' = []
407
430
  remaining = self._config.project_doc_max_bytes
408
431
  for path in self._discover_project_doc_paths():
409
432
  text = path.read_text()
@@ -421,9 +444,9 @@ class ContextManager:
421
444
  return None
422
445
  return "\n\n".join(docs)
423
446
 
424
- def _discover_project_doc_paths(self) -> list[Path]:
425
- seen: set[Path] = set()
426
- discovered: list[Path] = []
447
+ def _discover_project_doc_paths(self) -> 'typing.List[Path]':
448
+ seen: 'typing.Set[Path]' = set()
449
+ discovered: 'typing.List[Path]' = []
427
450
 
428
451
  search_dirs = self._project_search_dirs()
429
452
  for directory in search_dirs:
@@ -435,9 +458,9 @@ class ContextManager:
435
458
  break
436
459
  return discovered
437
460
 
438
- def _project_search_dirs(self) -> list[Path]:
461
+ def _project_search_dirs(self) -> 'typing.List[Path]':
439
462
  project_root = self._find_project_root()
440
- directories: list[Path] = []
463
+ directories: 'typing.List[Path]' = []
441
464
  current = self.cwd
442
465
  chain = [current]
443
466
  while current != project_root and current.parent != current:
@@ -447,13 +470,13 @@ class ContextManager:
447
470
  directories.extend(chain)
448
471
  return directories
449
472
 
450
- def _find_project_root(self) -> Path:
473
+ def _find_project_root(self) -> 'Path':
451
474
  for ancestor in [self.cwd, *self.cwd.parents]:
452
475
  if (ancestor / ".git").exists():
453
476
  return ancestor
454
477
  return self.cwd
455
478
 
456
- def _serialize_environment_context(self) -> str:
479
+ def _serialize_environment_context(self) -> 'str':
457
480
  lines = [
458
481
  "<environment_context>",
459
482
  f" <cwd>{self.cwd}</cwd>",
@@ -465,30 +488,30 @@ class ContextManager:
465
488
  return "\n".join(lines)
466
489
 
467
490
 
468
- def _input_text_item(text: str) -> JSONDict:
491
+ def _input_text_item(text: 'str') -> 'JSONDict':
469
492
  return {"type": "input_text", "text": text}
470
493
 
471
494
 
472
- def _normalize_text(value) -> str | None:
495
+ def _normalize_text(value) -> 'typing.Union[str, None]':
473
496
  if value is None:
474
497
  return None
475
498
  text = str(value).strip()
476
499
  return text or None
477
500
 
478
501
 
479
- def _normalize_int(value) -> int | None:
502
+ def _normalize_int(value) -> 'typing.Union[int, None]':
480
503
  if value is None:
481
504
  return None
482
505
  return int(value)
483
506
 
484
507
 
485
- def _default_collaboration_instructions(mode: CollaborationMode) -> str:
508
+ def _default_collaboration_instructions(mode: 'CollaborationMode') -> 'str':
486
509
  if mode == "plan":
487
510
  return PLAN_COLLABORATION_INSTRUCTIONS_PATH.read_text()
488
511
  return DEFAULT_COLLABORATION_INSTRUCTIONS_PATH.read_text()
489
512
 
490
513
 
491
- def _read_first_instruction_file(base: Path) -> str | None:
514
+ def _read_first_instruction_file(base: 'Path') -> 'typing.Union[str, None]':
492
515
  for candidate_name in (LOCAL_PROJECT_DOC_FILENAME, DEFAULT_PROJECT_DOC_FILENAME):
493
516
  candidate = base / candidate_name
494
517
  try:
@@ -502,10 +525,10 @@ def _read_first_instruction_file(base: Path) -> str | None:
502
525
 
503
526
 
504
527
  @lru_cache(maxsize=1)
505
- def _load_models_by_slug() -> dict[str, JSONDict]:
528
+ def _load_models_by_slug() -> 'typing.Dict[str, JSONDict]':
506
529
  payload = json.loads(DEFAULT_MODELS_PATH.read_text())
507
530
  models = payload.get("models", [])
508
- by_slug: dict[str, JSONDict] = {}
531
+ by_slug: 'typing.Dict[str, JSONDict]' = {}
509
532
  for model in models:
510
533
  slug = model.get("slug")
511
534
  if isinstance(slug, str):
@@ -513,7 +536,7 @@ def _load_models_by_slug() -> dict[str, JSONDict]:
513
536
  return by_slug
514
537
 
515
538
 
516
- def _resolve_personality_message(variables, personality: str | None) -> str:
539
+ def _resolve_personality_message(variables, personality: 'typing.Union[str, None]') -> 'str':
517
540
  if not isinstance(variables, dict):
518
541
  return ""
519
542
  normalized = (personality or "").strip().lower()
@@ -532,13 +555,13 @@ def _resolve_personality_message(variables, personality: str | None) -> str:
532
555
 
533
556
 
534
557
  def _discover_skill_files(
535
- root: Path,
536
- excluded_root: Path | None = None,
537
- ) -> list[Path]:
558
+ root: 'Path',
559
+ excluded_root: 'typing.Union[Path, None]' = None,
560
+ ) -> 'typing.List[Path]':
538
561
  if not root.exists() or not root.is_dir():
539
562
  return []
540
563
  excluded = excluded_root.resolve() if excluded_root is not None and excluded_root.exists() else None
541
- paths: list[Path] = []
564
+ paths: 'typing.List[Path]' = []
542
565
  for path in root.glob("**/SKILL.md"):
543
566
  resolved = path.resolve()
544
567
  if excluded is not None and (resolved == excluded or excluded in resolved.parents):
@@ -547,7 +570,7 @@ def _discover_skill_files(
547
570
  return sorted(paths)
548
571
 
549
572
 
550
- def _parse_skill_descriptor(path: Path, scope_rank: int) -> SkillDescriptor | None:
573
+ def _parse_skill_descriptor(path: 'Path', scope_rank: 'int') -> 'typing.Union[SkillDescriptor, None]':
551
574
  text = path.read_text()
552
575
  if not text.startswith("---\n"):
553
576
  return None
@@ -556,7 +579,7 @@ def _parse_skill_descriptor(path: Path, scope_rank: int) -> SkillDescriptor | No
556
579
  if end_index == -1:
557
580
  return None
558
581
  frontmatter = text[4:end_index]
559
- fields: dict[str, str] = {}
582
+ fields: 'typing.Dict[str, str]' = {}
560
583
  for line in frontmatter.splitlines():
561
584
  if ":" not in line:
562
585
  continue
@@ -574,7 +597,7 @@ def _parse_skill_descriptor(path: Path, scope_rank: int) -> SkillDescriptor | No
574
597
  )
575
598
 
576
599
 
577
- def _strip_yaml_string(value: str) -> str:
600
+ def _strip_yaml_string(value: 'str') -> 'str':
578
601
  if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
579
602
  return value[1:-1]
580
603
  return value