teddy-cli 0.1.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 (143) hide show
  1. teddy_cli-0.1.0.dist-info/LICENSE +677 -0
  2. teddy_cli-0.1.0.dist-info/METADATA +33 -0
  3. teddy_cli-0.1.0.dist-info/RECORD +143 -0
  4. teddy_cli-0.1.0.dist-info/WHEEL +4 -0
  5. teddy_cli-0.1.0.dist-info/entry_points.txt +3 -0
  6. teddy_executor/__init__.py +1 -0
  7. teddy_executor/__main__.py +335 -0
  8. teddy_executor/adapters/__init__.py +0 -0
  9. teddy_executor/adapters/inbound/__init__.py +0 -0
  10. teddy_executor/adapters/inbound/cli_formatter.py +107 -0
  11. teddy_executor/adapters/inbound/cli_helpers.py +249 -0
  12. teddy_executor/adapters/inbound/console_plan_reviewer.py +69 -0
  13. teddy_executor/adapters/inbound/session_cli_handlers.py +366 -0
  14. teddy_executor/adapters/inbound/textual_plan_reviewer.py +78 -0
  15. teddy_executor/adapters/inbound/textual_plan_reviewer_app.py +367 -0
  16. teddy_executor/adapters/inbound/textual_plan_reviewer_editor.py +281 -0
  17. teddy_executor/adapters/inbound/textual_plan_reviewer_execution.py +213 -0
  18. teddy_executor/adapters/inbound/textual_plan_reviewer_helpers.py +308 -0
  19. teddy_executor/adapters/inbound/textual_plan_reviewer_logic.py +345 -0
  20. teddy_executor/adapters/inbound/textual_plan_reviewer_previews.py +227 -0
  21. teddy_executor/adapters/inbound/textual_plan_reviewer_widgets.py +246 -0
  22. teddy_executor/adapters/outbound/__init__.py +7 -0
  23. teddy_executor/adapters/outbound/console_interactor.py +212 -0
  24. teddy_executor/adapters/outbound/console_interactor_ask_loop.py +121 -0
  25. teddy_executor/adapters/outbound/console_interactor_helpers.py +95 -0
  26. teddy_executor/adapters/outbound/console_tooling.py +62 -0
  27. teddy_executor/adapters/outbound/filesystem_helpers.py +61 -0
  28. teddy_executor/adapters/outbound/litellm_adapter.py +462 -0
  29. teddy_executor/adapters/outbound/local_file_system_adapter.py +300 -0
  30. teddy_executor/adapters/outbound/local_repo_tree_generator.py +96 -0
  31. teddy_executor/adapters/outbound/openrouter_hydrator.py +89 -0
  32. teddy_executor/adapters/outbound/shell_adapter.py +344 -0
  33. teddy_executor/adapters/outbound/shell_command_builder.py +105 -0
  34. teddy_executor/adapters/outbound/system_environment_adapter.py +62 -0
  35. teddy_executor/adapters/outbound/system_environment_inspector.py +54 -0
  36. teddy_executor/adapters/outbound/system_time_adapter.py +22 -0
  37. teddy_executor/adapters/outbound/web_scraper_adapter.py +346 -0
  38. teddy_executor/adapters/outbound/web_searcher_adapter.py +122 -0
  39. teddy_executor/adapters/outbound/yaml_config_adapter.py +105 -0
  40. teddy_executor/container.py +333 -0
  41. teddy_executor/core/__init__.py +0 -0
  42. teddy_executor/core/domain/__init__.py +0 -0
  43. teddy_executor/core/domain/models/__init__.py +44 -0
  44. teddy_executor/core/domain/models/action_ports.py +28 -0
  45. teddy_executor/core/domain/models/change_set.py +10 -0
  46. teddy_executor/core/domain/models/exceptions.py +40 -0
  47. teddy_executor/core/domain/models/execution_report.py +65 -0
  48. teddy_executor/core/domain/models/orchestrator_ports.py +26 -0
  49. teddy_executor/core/domain/models/plan.py +85 -0
  50. teddy_executor/core/domain/models/planning_ports.py +43 -0
  51. teddy_executor/core/domain/models/project_context.py +56 -0
  52. teddy_executor/core/domain/models/report_assembly_data.py +18 -0
  53. teddy_executor/core/domain/models/session.py +17 -0
  54. teddy_executor/core/domain/models/shell_output.py +12 -0
  55. teddy_executor/core/domain/models/web_search_results.py +26 -0
  56. teddy_executor/core/ports/__init__.py +0 -0
  57. teddy_executor/core/ports/inbound/__init__.py +0 -0
  58. teddy_executor/core/ports/inbound/edit_simulator.py +33 -0
  59. teddy_executor/core/ports/inbound/get_context_use_case.py +32 -0
  60. teddy_executor/core/ports/inbound/init.py +15 -0
  61. teddy_executor/core/ports/inbound/plan_parser.py +52 -0
  62. teddy_executor/core/ports/inbound/plan_reviewer.py +44 -0
  63. teddy_executor/core/ports/inbound/plan_validator.py +26 -0
  64. teddy_executor/core/ports/inbound/planning_use_case.py +30 -0
  65. teddy_executor/core/ports/inbound/run_plan_use_case.py +60 -0
  66. teddy_executor/core/ports/outbound/__init__.py +34 -0
  67. teddy_executor/core/ports/outbound/config_service.py +29 -0
  68. teddy_executor/core/ports/outbound/environment_inspector.py +30 -0
  69. teddy_executor/core/ports/outbound/execution_report_assembler.py +19 -0
  70. teddy_executor/core/ports/outbound/file_system_manager.py +131 -0
  71. teddy_executor/core/ports/outbound/llm_client.py +90 -0
  72. teddy_executor/core/ports/outbound/markdown_report_formatter.py +26 -0
  73. teddy_executor/core/ports/outbound/prompt_manager.py +55 -0
  74. teddy_executor/core/ports/outbound/repo_tree_generator.py +17 -0
  75. teddy_executor/core/ports/outbound/session_loop_guard.py +16 -0
  76. teddy_executor/core/ports/outbound/session_manager.py +97 -0
  77. teddy_executor/core/ports/outbound/session_repository.py +65 -0
  78. teddy_executor/core/ports/outbound/shell_executor.py +24 -0
  79. teddy_executor/core/ports/outbound/system_environment.py +25 -0
  80. teddy_executor/core/ports/outbound/time_service.py +28 -0
  81. teddy_executor/core/ports/outbound/user_interactor.py +126 -0
  82. teddy_executor/core/ports/outbound/web_scraper.py +24 -0
  83. teddy_executor/core/ports/outbound/web_searcher.py +25 -0
  84. teddy_executor/core/services/__init__.py +0 -0
  85. teddy_executor/core/services/action_changeset_builder.py +90 -0
  86. teddy_executor/core/services/action_diff_manager.py +110 -0
  87. teddy_executor/core/services/action_dispatcher.py +142 -0
  88. teddy_executor/core/services/action_executor.py +209 -0
  89. teddy_executor/core/services/action_factory.py +197 -0
  90. teddy_executor/core/services/action_parser_complex.py +216 -0
  91. teddy_executor/core/services/action_parser_strategies.py +84 -0
  92. teddy_executor/core/services/context_service.py +437 -0
  93. teddy_executor/core/services/edit_simulator.py +128 -0
  94. teddy_executor/core/services/execution_orchestrator.py +295 -0
  95. teddy_executor/core/services/execution_report_assembler.py +62 -0
  96. teddy_executor/core/services/init_service.py +80 -0
  97. teddy_executor/core/services/markdown_plan_parser.py +309 -0
  98. teddy_executor/core/services/markdown_report_formatter.py +143 -0
  99. teddy_executor/core/services/parser_infrastructure.py +222 -0
  100. teddy_executor/core/services/parser_metadata.py +153 -0
  101. teddy_executor/core/services/parser_reporting.py +267 -0
  102. teddy_executor/core/services/plan_validator.py +82 -0
  103. teddy_executor/core/services/planning_service.py +242 -0
  104. teddy_executor/core/services/prompt_manager.py +146 -0
  105. teddy_executor/core/services/session_lifecycle_manager.py +228 -0
  106. teddy_executor/core/services/session_loop_guard.py +46 -0
  107. teddy_executor/core/services/session_orchestrator.py +538 -0
  108. teddy_executor/core/services/session_planner.py +43 -0
  109. teddy_executor/core/services/session_pruning_service.py +438 -0
  110. teddy_executor/core/services/session_replanner.py +105 -0
  111. teddy_executor/core/services/session_repository.py +194 -0
  112. teddy_executor/core/services/session_service.py +529 -0
  113. teddy_executor/core/services/templates/execution_report.md.j2 +290 -0
  114. teddy_executor/core/services/validation_rules/__init__.py +4 -0
  115. teddy_executor/core/services/validation_rules/edit.py +207 -0
  116. teddy_executor/core/services/validation_rules/edit_matcher.py +247 -0
  117. teddy_executor/core/services/validation_rules/edit_matcher_heuristics.py +84 -0
  118. teddy_executor/core/services/validation_rules/execute.py +37 -0
  119. teddy_executor/core/services/validation_rules/filesystem.py +73 -0
  120. teddy_executor/core/services/validation_rules/helpers.py +178 -0
  121. teddy_executor/core/services/validation_rules/message.py +29 -0
  122. teddy_executor/core/utils/__init__.py +1 -0
  123. teddy_executor/core/utils/diff.py +57 -0
  124. teddy_executor/core/utils/io.py +75 -0
  125. teddy_executor/core/utils/markdown.py +131 -0
  126. teddy_executor/core/utils/serialization.py +39 -0
  127. teddy_executor/core/utils/string.py +351 -0
  128. teddy_executor/prompts.py +45 -0
  129. teddy_executor/registries/__init__.py +1 -0
  130. teddy_executor/registries/infrastructure.py +147 -0
  131. teddy_executor/registries/reviewer.py +57 -0
  132. teddy_executor/registries/validators.py +47 -0
  133. teddy_executor/resources/__init__.py +1 -0
  134. teddy_executor/resources/config/.gitignore +2 -0
  135. teddy_executor/resources/config/__init__.py +1 -0
  136. teddy_executor/resources/config/config.yaml +49 -0
  137. teddy_executor/resources/config/init.context +5 -0
  138. teddy_executor/resources/config/prompts/architect.xml +462 -0
  139. teddy_executor/resources/config/prompts/assistant.xml +336 -0
  140. teddy_executor/resources/config/prompts/debugger.xml +456 -0
  141. teddy_executor/resources/config/prompts/developer.xml +481 -0
  142. teddy_executor/resources/config/prompts/pathfinder.xml +502 -0
  143. teddy_executor/resources/config/prompts/prototyper.xml +425 -0
@@ -0,0 +1,300 @@
1
+ import logging
2
+ import os
3
+ from pathlib import Path
4
+ from typing import List, Sequence, TextIO
5
+ from teddy_executor.core.domain.models.plan import DEFAULT_SIMILARITY_THRESHOLD
6
+ from teddy_executor.core.ports.inbound.edit_simulator import EditPair, IEditSimulator
7
+ from teddy_executor.core.ports.outbound.file_system_manager import IFileSystemManager
8
+ from teddy_executor.core.utils.string import truncate_lines
9
+
10
+ # Configure debug logging
11
+ if os.environ.get("TEDDY_DEBUG"):
12
+ logging.basicConfig(level=logging.DEBUG)
13
+ else:
14
+ logging.basicConfig(level=logging.INFO)
15
+
16
+ from teddy_executor.core.domain.models import (
17
+ FileAlreadyExistsError,
18
+ )
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class LocalFileSystemAdapter(IFileSystemManager):
24
+ """
25
+ An adapter that implements file system operations on the local machine.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ edit_simulator: IEditSimulator,
31
+ root_dir: str = ".",
32
+ max_read_lines: int = 1000,
33
+ ):
34
+ self._edit_simulator = edit_simulator
35
+ self.root_dir = Path(root_dir)
36
+ self.max_read_lines = max_read_lines
37
+
38
+ def _resolve_path(self, path: str) -> Path:
39
+ """
40
+ Resolves a path relative to the root_dir.
41
+ """
42
+ path_obj = Path(path)
43
+ if not hasattr(self, "_resolved_root"):
44
+ self._resolved_root = self.root_dir.resolve()
45
+ if path_obj.is_absolute():
46
+ # Optimization: Only resolve if there are symlinks or ".." components.
47
+ if ".." in str(path) or path_obj.is_symlink():
48
+ return path_obj.resolve()
49
+ return path_obj
50
+
51
+ # Systemic Fix: Strictly follow project-root-relative convention.
52
+ # We strip leading slashes and Windows drive letters to ensure the path
53
+ # is always joined with our controlled root_dir.
54
+ clean_path = str(path).replace("\\", "/")
55
+
56
+ # Remove drive letter (e.g., C:) if present
57
+ if ":" in clean_path and clean_path[1] == ":":
58
+ clean_path = clean_path[2:]
59
+
60
+ clean_path = clean_path.lstrip("/")
61
+
62
+ # Join and resolve. This ensures we have a canonical, absolute path
63
+ # within our root_dir, preventing macOS resolution hangs.
64
+ # Optimization: Pre-resolve root_dir once
65
+ base = self._resolved_root
66
+ target = base / clean_path
67
+
68
+ # Only resolve if strictly necessary to avoid performance hits in loops
69
+ if ".." in clean_path or target.is_symlink():
70
+ return target.resolve()
71
+ return target
72
+
73
+ def get_context_paths(self) -> list[str]:
74
+ """
75
+ Reads all .teddy/*.context files and returns a deduplicated list of paths.
76
+ """
77
+ teddy_dir = self.root_dir / ".teddy"
78
+ if not teddy_dir.is_dir():
79
+ return []
80
+
81
+ context_files = [str(p) for p in teddy_dir.glob("*.context")]
82
+ return self.resolve_paths_from_files(context_files)
83
+
84
+ def resolve_paths_from_files(self, file_paths: Sequence[str]) -> list[str]:
85
+ """
86
+ Reads a list of context files and returns a deduplicated list of the paths they contain.
87
+ """
88
+ all_paths = set()
89
+ for path in file_paths:
90
+ try:
91
+ content = self.read_file(path)
92
+ for line in content.splitlines():
93
+ stripped_line = line.strip()
94
+ if stripped_line and not stripped_line.startswith("#"):
95
+ # Skip strings with illegal filename characters on Windows/Unix
96
+ if any(c in stripped_line for c in '*?"<>|'):
97
+ continue
98
+ all_paths.add(stripped_line)
99
+ except (FileNotFoundError, OSError, ValueError):
100
+ continue
101
+
102
+ return sorted(list(all_paths))
103
+
104
+ def list_directory(self, path: str) -> list[str]:
105
+ """
106
+ Lists the names of files and directories in the specified path.
107
+ """
108
+ dir_path = self._resolve_path(path)
109
+ if not dir_path.is_dir():
110
+ raise FileNotFoundError(f"Directory not found: {path}")
111
+ return [p.name for p in dir_path.iterdir()]
112
+
113
+ def list_directory_recursive(self, path: str) -> list[str]:
114
+ """
115
+ Lists all files in a directory and its subdirectories, respecting ignores.
116
+ """
117
+ from teddy_executor.adapters.outbound.filesystem_helpers import walk_recursive
118
+
119
+ dir_path = self._resolve_path(path)
120
+ if not dir_path.is_dir():
121
+ raise FileNotFoundError(f"Directory not found: {path}")
122
+
123
+ if not hasattr(self, "_resolved_root"):
124
+ self._resolved_root = self.root_dir.resolve()
125
+
126
+ spec = self._get_ignore_spec()
127
+ files = []
128
+
129
+ for entry, is_dir in walk_recursive(self._resolved_root, dir_path, spec):
130
+ if not is_dir and entry.is_file():
131
+ try:
132
+ rel_path = entry.relative_to(self._resolved_root)
133
+ except ValueError:
134
+ rel_path = entry
135
+ files.append(str(rel_path).replace("\\", "/"))
136
+
137
+ return sorted(files)
138
+
139
+ def _get_ignore_spec(self):
140
+ """Loads and caches the ignore specification."""
141
+ if not hasattr(self, "_ignore_spec"):
142
+ from teddy_executor.adapters.outbound.filesystem_helpers import (
143
+ load_ignore_spec,
144
+ )
145
+
146
+ if not hasattr(self, "_resolved_root"):
147
+ self._resolved_root = self.root_dir.resolve()
148
+
149
+ self._ignore_spec = load_ignore_spec(self._resolved_root)
150
+ return self._ignore_spec
151
+
152
+ def get_mtime(self, path: str) -> float:
153
+ """
154
+ Returns the modification time of a file or directory as a timestamp.
155
+ """
156
+ target = self._resolve_path(path)
157
+ return target.stat().st_mtime
158
+
159
+ def move_directory(self, old_path: str, new_path: str) -> None:
160
+ """
161
+ Moves or renames a directory.
162
+ """
163
+ import shutil
164
+
165
+ source = self._resolve_path(old_path)
166
+ destination = self._resolve_path(new_path)
167
+
168
+ if not source.exists():
169
+ raise FileNotFoundError(f"Source directory not found: {old_path}")
170
+ if destination.exists():
171
+ raise FileExistsError(f"Destination directory already exists: {new_path}")
172
+
173
+ shutil.move(str(source), str(destination))
174
+
175
+ def open_file_for_append(self, path: str) -> TextIO:
176
+ """
177
+ Opens a file for appending, creating parent directories if needed.
178
+ Returns a TextIO file-like object for writing.
179
+ """
180
+ file_path = self._resolve_path(path)
181
+ file_path.parent.mkdir(parents=True, exist_ok=True)
182
+ return open(file_path, "a", encoding="utf-8")
183
+
184
+ def read_files_in_vault(self, paths: list[str]) -> dict[str, str | None]:
185
+ """
186
+ Reads the content of multiple files specified in a list.
187
+ Returns content for found files and None for files that are not found.
188
+ """
189
+ contents: dict[str, str | None] = {}
190
+ for path in paths:
191
+ try:
192
+ # read_file already calls _resolve_path, which joins with root_dir.
193
+ contents[path] = self.read_file(path)
194
+ except FileNotFoundError:
195
+ contents[path] = None # Mark not found files with None
196
+ return contents
197
+
198
+ def path_exists(self, path: str) -> bool:
199
+ """
200
+ Checks if a path (file or directory) exists relative to the root_dir.
201
+ """
202
+ return self._resolve_path(path).exists()
203
+
204
+ def is_dir(self, path: str) -> bool:
205
+ """
206
+ Returns True if the path exists and is a directory.
207
+ """
208
+ return self._resolve_path(path).is_dir()
209
+
210
+ def create_directory(self, path: str) -> None:
211
+ """
212
+ Creates a directory, including any necessary parent directories.
213
+ Does not raise an error if the directory already exists.
214
+ """
215
+ self._resolve_path(path).mkdir(parents=True, exist_ok=True)
216
+
217
+ def write_file(self, path: str, content: str) -> None:
218
+ """
219
+ Writes content to a file, creating it if it doesn't exist
220
+ and overwriting it if it does.
221
+ """
222
+ self._resolve_path(path).write_text(content, encoding="utf-8")
223
+
224
+ def create_file(self, path: str, content: str, overwrite: bool = False) -> None:
225
+ """
226
+ Creates a new file with the given content.
227
+ """
228
+ try:
229
+ file_path = self._resolve_path(path)
230
+ file_path.parent.mkdir(parents=True, exist_ok=True)
231
+
232
+ mode = "w" if overwrite else "x"
233
+ with open(file_path, mode, encoding="utf-8") as f:
234
+ f.write(content)
235
+ except FileExistsError as e:
236
+ # Raise a domain-specific exception to conform to the port's contract.
237
+ raise FileAlreadyExistsError(f"File exists: {path}", file_path=path) from e
238
+ except IOError as e:
239
+ # In a real-world scenario, we might want a more specific
240
+ # domain exception here, but for now, this is sufficient.
241
+ # For example, to handle permission errors.
242
+ raise IOError(f"Failed to create file at {path}: {e}") from e
243
+
244
+ def read_file(self, path: str) -> str:
245
+ """
246
+ Reads the content of a file from the specified path.
247
+ """
248
+ try:
249
+ content = self.read_raw_file(path)
250
+ return truncate_lines(
251
+ content,
252
+ max_lines=self.max_read_lines,
253
+ direction="head",
254
+ action_type="read",
255
+ )
256
+ except FileNotFoundError:
257
+ # Re-raise to conform to the port's contract
258
+ raise
259
+ except IOError as e:
260
+ raise IOError(f"Failed to read file at {path}: {e}") from e
261
+
262
+ def read_raw_file(self, path: str) -> str:
263
+ """
264
+ Reads the full content of a file from the specified path.
265
+ """
266
+ try:
267
+ return self._resolve_path(path).read_text(encoding="utf-8")
268
+ except FileNotFoundError:
269
+ raise
270
+ except IOError as e:
271
+ raise IOError(f"Failed to read raw file at {path}: {e}") from e
272
+
273
+ def edit_file(
274
+ self,
275
+ path: str,
276
+ edits: list[dict[str, str]],
277
+ similarity_threshold: float = DEFAULT_SIMILARITY_THRESHOLD,
278
+ match_all: bool = False,
279
+ ) -> list[float]:
280
+ """
281
+ Modifies an existing file by applying a list of find-and-replace blocks.
282
+ """
283
+ file_path = self._resolve_path(path)
284
+ content = file_path.read_text(encoding="utf-8")
285
+
286
+ # Cast to match the port's expected EditPair structure
287
+ cast_edits: List[EditPair] = []
288
+ for e in edits:
289
+ pair: EditPair = {"find": e["find"], "replace": e["replace"]}
290
+ cast_edits.append(pair)
291
+
292
+ new_content, scores = self._edit_simulator.simulate_edits(
293
+ content,
294
+ cast_edits,
295
+ threshold=similarity_threshold,
296
+ match_all=match_all,
297
+ )
298
+
299
+ file_path.write_text(new_content, encoding="utf-8")
300
+ return scores
@@ -0,0 +1,96 @@
1
+ import os
2
+ from pathlib import Path
3
+ from teddy_executor.core.ports.outbound.repo_tree_generator import IRepoTreeGenerator
4
+
5
+
6
+ class _RecursiveListFormatter:
7
+ """
8
+ A helper class to format a set of paths into a recursive "ls -R" style list.
9
+ """
10
+
11
+ def __init__(self, root_dir: Path, included_paths: set[Path]):
12
+ self.root_dir = root_dir
13
+ self.included_paths = included_paths
14
+
15
+ def format(self) -> str:
16
+ """Generates the recursive list string."""
17
+ sections: list[str] = []
18
+ # Gather all directories that are included (including root)
19
+ directories = sorted(
20
+ [p for p in self.included_paths if p.is_dir() and not p.is_symlink()]
21
+ + [self.root_dir],
22
+ key=lambda p: str(p.relative_to(self.root_dir)).lower(),
23
+ )
24
+
25
+ for directory in directories:
26
+ section_content = self._format_section(directory)
27
+ if section_content:
28
+ sections.append(section_content)
29
+
30
+ return "\n\n".join(sections)
31
+
32
+ def _format_section(self, directory: Path) -> str:
33
+ """Formats a single directory section."""
34
+ children = sorted(
35
+ [p for p in directory.iterdir() if p in self.included_paths],
36
+ key=lambda p: p.name.lower(),
37
+ )
38
+
39
+ if not children:
40
+ return ""
41
+
42
+ lines = []
43
+ if directory != self.root_dir:
44
+ rel_path = directory.relative_to(self.root_dir)
45
+ # Poka-Yoke: Always use forward slashes for the tree protocol
46
+ posix_rel_path = str(rel_path).replace(os.sep, "/")
47
+ lines.append(f"./{posix_rel_path}:")
48
+
49
+ for child in children:
50
+ lines.append(child.name)
51
+
52
+ return "\n".join(lines)
53
+
54
+
55
+ class LocalRepoTreeGenerator(IRepoTreeGenerator):
56
+ """
57
+ An adapter that generates a file tree for the local repository,
58
+ respecting .gitignore and .teddyignore rules.
59
+ """
60
+
61
+ def __init__(self, root_dir: str = "."):
62
+ from teddy_executor.adapters.outbound.filesystem_helpers import (
63
+ load_ignore_spec,
64
+ )
65
+
66
+ self.root_dir = Path(root_dir).resolve()
67
+ self.ignore_spec = load_ignore_spec(self.root_dir)
68
+
69
+ def _get_included_paths(self) -> set[Path]:
70
+ """
71
+ Walks the directory and returns a set of all files and parent directories
72
+ that are not excluded by the ignore spec.
73
+ """
74
+ from teddy_executor.adapters.outbound.filesystem_helpers import walk_recursive
75
+
76
+ included_paths: set[Path] = set()
77
+ for entry, _ in walk_recursive(self.root_dir, self.root_dir, self.ignore_spec):
78
+ # If not ignored, add to set
79
+ included_paths.add(entry)
80
+
81
+ # Ensure parent connectivity
82
+ for parent in entry.parents:
83
+ if parent == self.root_dir:
84
+ break
85
+ included_paths.add(parent)
86
+
87
+ return included_paths
88
+
89
+ def generate_tree(self) -> str:
90
+ """
91
+ Generates a string representation of the file tree by gathering paths
92
+ and then delegating to a formatter.
93
+ """
94
+ included_paths = self._get_included_paths()
95
+ formatter = _RecursiveListFormatter(self.root_dir, included_paths)
96
+ return formatter.format()
@@ -0,0 +1,89 @@
1
+ import re
2
+ import requests
3
+ from typing import Any, Dict, List, Optional
4
+ from teddy_executor.adapters.outbound.litellm_adapter import IOpenRouterHydrator
5
+
6
+
7
+ class OpenRouterMetadataHydrator(IOpenRouterHydrator):
8
+ """
9
+ Fetches and caches model metadata from the OpenRouter API.
10
+ Supports suffix-stripping for versioned models.
11
+ """
12
+
13
+ API_URL = "https://openrouter.ai/api/v1/models"
14
+ TIMEOUT = 10.0
15
+
16
+ def __init__(self):
17
+ self._cached_models: Optional[List[Dict[str, Any]]] = None
18
+
19
+ def _fetch_models(self) -> List[Dict[str, Any]]:
20
+ """Fetches the live catalog from OpenRouter with a timeout."""
21
+ if self._cached_models is not None:
22
+ return self._cached_models
23
+
24
+ try:
25
+ # Note: In tests, the fixture will point to the local mock server
26
+ # if we use a relative URL or handle the base URL correctly.
27
+ # To allow testing, we check for an environment variable or just use the URL.
28
+ # However, since the test fixture provides a URL, we'll let the test
29
+ # pass the instance the URL or just rely on the fact that requests
30
+ # can be mocked at the session level if needed.
31
+ # For this implementation, we follow the slice requirements.
32
+ response = requests.get(self.API_URL, timeout=self.TIMEOUT)
33
+ response.raise_for_status()
34
+ data = response.json()
35
+ self._cached_models = data.get("data", [])
36
+ return self._cached_models or []
37
+ except (requests.RequestException, ValueError):
38
+ return []
39
+
40
+ def get_metadata(self, model_id: str) -> Optional[Dict[str, Any]]:
41
+ """
42
+ Returns metadata for a model, stripping suffixes if necessary.
43
+ """
44
+ models = self._fetch_models()
45
+ if not models:
46
+ return None
47
+
48
+ # Strip openrouter/ prefix if present to match catalog IDs
49
+ clean_id = model_id.removeprefix("openrouter/")
50
+
51
+ # Strip colon-based routing shortcuts (e.g., :nitro, :floor)
52
+ # OpenRouter appends these to route requests; they must be removed before ID lookup.
53
+ clean_id = re.sub(r":[^/:]+$", "", clean_id)
54
+
55
+ # 1. Try exact match
56
+ metadata = self._find_model(models, clean_id)
57
+ if metadata:
58
+ return metadata
59
+
60
+ # 2. Try suffix stripping (e.g. -20240525)
61
+ # Matches patterns like -20240525 or -202405251230
62
+ stripped_id = re.sub(r"-\d{8,12}$", "", clean_id)
63
+ if stripped_id != clean_id:
64
+ metadata = self._find_model(models, stripped_id)
65
+ if metadata:
66
+ return metadata
67
+
68
+ return None
69
+
70
+ def _find_model(
71
+ self, models: List[Dict[str, Any]], model_id: str
72
+ ) -> Optional[Dict[str, Any]]:
73
+ """Helper to find a model in the list and format the result."""
74
+ for m in models:
75
+ if m.get("id") == model_id:
76
+ pricing = m.get("pricing", {})
77
+ try:
78
+ input_cost = float(pricing.get("prompt", 0))
79
+ output_cost = float(pricing.get("completion", 0))
80
+ except (ValueError, TypeError):
81
+ return None
82
+ return {
83
+ "context_window": m.get("context_length", 0),
84
+ "pricing": {
85
+ "input_cost_per_token": input_cost,
86
+ "output_cost_per_token": output_cost,
87
+ },
88
+ }
89
+ return None