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.
- teddy_cli-0.1.0.dist-info/LICENSE +677 -0
- teddy_cli-0.1.0.dist-info/METADATA +33 -0
- teddy_cli-0.1.0.dist-info/RECORD +143 -0
- teddy_cli-0.1.0.dist-info/WHEEL +4 -0
- teddy_cli-0.1.0.dist-info/entry_points.txt +3 -0
- teddy_executor/__init__.py +1 -0
- teddy_executor/__main__.py +335 -0
- teddy_executor/adapters/__init__.py +0 -0
- teddy_executor/adapters/inbound/__init__.py +0 -0
- teddy_executor/adapters/inbound/cli_formatter.py +107 -0
- teddy_executor/adapters/inbound/cli_helpers.py +249 -0
- teddy_executor/adapters/inbound/console_plan_reviewer.py +69 -0
- teddy_executor/adapters/inbound/session_cli_handlers.py +366 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer.py +78 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_app.py +367 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_editor.py +281 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_execution.py +213 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_helpers.py +308 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_logic.py +345 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_previews.py +227 -0
- teddy_executor/adapters/inbound/textual_plan_reviewer_widgets.py +246 -0
- teddy_executor/adapters/outbound/__init__.py +7 -0
- teddy_executor/adapters/outbound/console_interactor.py +212 -0
- teddy_executor/adapters/outbound/console_interactor_ask_loop.py +121 -0
- teddy_executor/adapters/outbound/console_interactor_helpers.py +95 -0
- teddy_executor/adapters/outbound/console_tooling.py +62 -0
- teddy_executor/adapters/outbound/filesystem_helpers.py +61 -0
- teddy_executor/adapters/outbound/litellm_adapter.py +462 -0
- teddy_executor/adapters/outbound/local_file_system_adapter.py +300 -0
- teddy_executor/adapters/outbound/local_repo_tree_generator.py +96 -0
- teddy_executor/adapters/outbound/openrouter_hydrator.py +89 -0
- teddy_executor/adapters/outbound/shell_adapter.py +344 -0
- teddy_executor/adapters/outbound/shell_command_builder.py +105 -0
- teddy_executor/adapters/outbound/system_environment_adapter.py +62 -0
- teddy_executor/adapters/outbound/system_environment_inspector.py +54 -0
- teddy_executor/adapters/outbound/system_time_adapter.py +22 -0
- teddy_executor/adapters/outbound/web_scraper_adapter.py +346 -0
- teddy_executor/adapters/outbound/web_searcher_adapter.py +122 -0
- teddy_executor/adapters/outbound/yaml_config_adapter.py +105 -0
- teddy_executor/container.py +333 -0
- teddy_executor/core/__init__.py +0 -0
- teddy_executor/core/domain/__init__.py +0 -0
- teddy_executor/core/domain/models/__init__.py +44 -0
- teddy_executor/core/domain/models/action_ports.py +28 -0
- teddy_executor/core/domain/models/change_set.py +10 -0
- teddy_executor/core/domain/models/exceptions.py +40 -0
- teddy_executor/core/domain/models/execution_report.py +65 -0
- teddy_executor/core/domain/models/orchestrator_ports.py +26 -0
- teddy_executor/core/domain/models/plan.py +85 -0
- teddy_executor/core/domain/models/planning_ports.py +43 -0
- teddy_executor/core/domain/models/project_context.py +56 -0
- teddy_executor/core/domain/models/report_assembly_data.py +18 -0
- teddy_executor/core/domain/models/session.py +17 -0
- teddy_executor/core/domain/models/shell_output.py +12 -0
- teddy_executor/core/domain/models/web_search_results.py +26 -0
- teddy_executor/core/ports/__init__.py +0 -0
- teddy_executor/core/ports/inbound/__init__.py +0 -0
- teddy_executor/core/ports/inbound/edit_simulator.py +33 -0
- teddy_executor/core/ports/inbound/get_context_use_case.py +32 -0
- teddy_executor/core/ports/inbound/init.py +15 -0
- teddy_executor/core/ports/inbound/plan_parser.py +52 -0
- teddy_executor/core/ports/inbound/plan_reviewer.py +44 -0
- teddy_executor/core/ports/inbound/plan_validator.py +26 -0
- teddy_executor/core/ports/inbound/planning_use_case.py +30 -0
- teddy_executor/core/ports/inbound/run_plan_use_case.py +60 -0
- teddy_executor/core/ports/outbound/__init__.py +34 -0
- teddy_executor/core/ports/outbound/config_service.py +29 -0
- teddy_executor/core/ports/outbound/environment_inspector.py +30 -0
- teddy_executor/core/ports/outbound/execution_report_assembler.py +19 -0
- teddy_executor/core/ports/outbound/file_system_manager.py +131 -0
- teddy_executor/core/ports/outbound/llm_client.py +90 -0
- teddy_executor/core/ports/outbound/markdown_report_formatter.py +26 -0
- teddy_executor/core/ports/outbound/prompt_manager.py +55 -0
- teddy_executor/core/ports/outbound/repo_tree_generator.py +17 -0
- teddy_executor/core/ports/outbound/session_loop_guard.py +16 -0
- teddy_executor/core/ports/outbound/session_manager.py +97 -0
- teddy_executor/core/ports/outbound/session_repository.py +65 -0
- teddy_executor/core/ports/outbound/shell_executor.py +24 -0
- teddy_executor/core/ports/outbound/system_environment.py +25 -0
- teddy_executor/core/ports/outbound/time_service.py +28 -0
- teddy_executor/core/ports/outbound/user_interactor.py +126 -0
- teddy_executor/core/ports/outbound/web_scraper.py +24 -0
- teddy_executor/core/ports/outbound/web_searcher.py +25 -0
- teddy_executor/core/services/__init__.py +0 -0
- teddy_executor/core/services/action_changeset_builder.py +90 -0
- teddy_executor/core/services/action_diff_manager.py +110 -0
- teddy_executor/core/services/action_dispatcher.py +142 -0
- teddy_executor/core/services/action_executor.py +209 -0
- teddy_executor/core/services/action_factory.py +197 -0
- teddy_executor/core/services/action_parser_complex.py +216 -0
- teddy_executor/core/services/action_parser_strategies.py +84 -0
- teddy_executor/core/services/context_service.py +437 -0
- teddy_executor/core/services/edit_simulator.py +128 -0
- teddy_executor/core/services/execution_orchestrator.py +295 -0
- teddy_executor/core/services/execution_report_assembler.py +62 -0
- teddy_executor/core/services/init_service.py +80 -0
- teddy_executor/core/services/markdown_plan_parser.py +309 -0
- teddy_executor/core/services/markdown_report_formatter.py +143 -0
- teddy_executor/core/services/parser_infrastructure.py +222 -0
- teddy_executor/core/services/parser_metadata.py +153 -0
- teddy_executor/core/services/parser_reporting.py +267 -0
- teddy_executor/core/services/plan_validator.py +82 -0
- teddy_executor/core/services/planning_service.py +242 -0
- teddy_executor/core/services/prompt_manager.py +146 -0
- teddy_executor/core/services/session_lifecycle_manager.py +228 -0
- teddy_executor/core/services/session_loop_guard.py +46 -0
- teddy_executor/core/services/session_orchestrator.py +538 -0
- teddy_executor/core/services/session_planner.py +43 -0
- teddy_executor/core/services/session_pruning_service.py +438 -0
- teddy_executor/core/services/session_replanner.py +105 -0
- teddy_executor/core/services/session_repository.py +194 -0
- teddy_executor/core/services/session_service.py +529 -0
- teddy_executor/core/services/templates/execution_report.md.j2 +290 -0
- teddy_executor/core/services/validation_rules/__init__.py +4 -0
- teddy_executor/core/services/validation_rules/edit.py +207 -0
- teddy_executor/core/services/validation_rules/edit_matcher.py +247 -0
- teddy_executor/core/services/validation_rules/edit_matcher_heuristics.py +84 -0
- teddy_executor/core/services/validation_rules/execute.py +37 -0
- teddy_executor/core/services/validation_rules/filesystem.py +73 -0
- teddy_executor/core/services/validation_rules/helpers.py +178 -0
- teddy_executor/core/services/validation_rules/message.py +29 -0
- teddy_executor/core/utils/__init__.py +1 -0
- teddy_executor/core/utils/diff.py +57 -0
- teddy_executor/core/utils/io.py +75 -0
- teddy_executor/core/utils/markdown.py +131 -0
- teddy_executor/core/utils/serialization.py +39 -0
- teddy_executor/core/utils/string.py +351 -0
- teddy_executor/prompts.py +45 -0
- teddy_executor/registries/__init__.py +1 -0
- teddy_executor/registries/infrastructure.py +147 -0
- teddy_executor/registries/reviewer.py +57 -0
- teddy_executor/registries/validators.py +47 -0
- teddy_executor/resources/__init__.py +1 -0
- teddy_executor/resources/config/.gitignore +2 -0
- teddy_executor/resources/config/__init__.py +1 -0
- teddy_executor/resources/config/config.yaml +49 -0
- teddy_executor/resources/config/init.context +5 -0
- teddy_executor/resources/config/prompts/architect.xml +462 -0
- teddy_executor/resources/config/prompts/assistant.xml +336 -0
- teddy_executor/resources/config/prompts/debugger.xml +456 -0
- teddy_executor/resources/config/prompts/developer.xml +481 -0
- teddy_executor/resources/config/prompts/pathfinder.xml +502 -0
- 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
|