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,437 @@
1
+ import concurrent.futures
2
+ import json
3
+ from pathlib import Path
4
+ from typing import Dict, List, Optional, Sequence
5
+ from teddy_executor.core.domain.models import ProjectContext, ContextItem
6
+ from teddy_executor.core.utils.markdown import (
7
+ get_fence_for_content,
8
+ get_language_from_path,
9
+ is_session_file_path,
10
+ get_session_history_display_name,
11
+ get_session_history_sort_key,
12
+ )
13
+ from teddy_executor.core.ports.inbound.get_context_use_case import IGetContextUseCase
14
+ from teddy_executor.core.ports.outbound.file_system_manager import IFileSystemManager
15
+ from teddy_executor.core.ports.outbound.repo_tree_generator import IRepoTreeGenerator
16
+ from teddy_executor.core.ports.outbound.environment_inspector import (
17
+ IEnvironmentInspector,
18
+ )
19
+ from teddy_executor.core.ports.outbound.llm_client import ILlmClient
20
+ from teddy_executor.core.ports.outbound.web_scraper import WebScraper as IWebScraper
21
+
22
+
23
+ class ContextService(IGetContextUseCase):
24
+ """
25
+ Application service for orchestrating the gathering of project context.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ file_system_manager: IFileSystemManager,
31
+ repo_tree_generator: IRepoTreeGenerator,
32
+ environment_inspector: IEnvironmentInspector,
33
+ llm_client: ILlmClient,
34
+ web_scraper: IWebScraper,
35
+ ):
36
+ self._file_system_manager = file_system_manager
37
+ self._repo_tree_generator = repo_tree_generator
38
+ self._environment_inspector = environment_inspector
39
+ self._llm_client = llm_client
40
+ self._web_scraper = web_scraper
41
+
42
+ def get_context(
43
+ self,
44
+ context_files: Optional[Dict[str, Sequence[str]]] = None,
45
+ include_tokens: bool = True,
46
+ agent_name: str = "Unknown",
47
+ total_window: int = 0,
48
+ cache_dir: Optional[str] = None,
49
+ current_turn: Optional[str] = None,
50
+ system_prompt_tokens: int = 0,
51
+ ) -> ProjectContext:
52
+ """
53
+ Gathers all project context information by orchestrating its dependencies.
54
+ """
55
+ system_info = self._environment_inspector.get_environment_info()
56
+ git_status = self._environment_inspector.get_git_status()
57
+ repo_tree = self._repo_tree_generator.generate_tree()
58
+
59
+ scoped_paths, all_resolved_paths = self._resolve_scoped_paths(context_files)
60
+
61
+ local_paths = [p for p in all_resolved_paths if not self._is_url(p)]
62
+ urls = [p for p in all_resolved_paths if self._is_url(p)]
63
+
64
+ file_contents = self._file_system_manager.read_files_in_vault(local_paths)
65
+
66
+ # Fetch remote content with session-level caching
67
+ web_cache = self._load_web_cache(cache_dir)
68
+ for url in urls:
69
+ if url in web_cache:
70
+ file_contents[url] = web_cache[url]
71
+ else:
72
+ try:
73
+ content = self._web_scraper.get_content(url)
74
+ file_contents[url] = content
75
+ web_cache[url] = content
76
+ if cache_dir:
77
+ self._save_web_cache(cache_dir, web_cache)
78
+ except Exception:
79
+ file_contents[url] = None
80
+
81
+ content = self._format_content(
82
+ repo_tree, scoped_paths, file_contents, git_status
83
+ )
84
+ content_tokens = (
85
+ self._llm_client.get_text_token_count(content) if include_tokens else 0
86
+ )
87
+
88
+ return ProjectContext(
89
+ header=self._format_header(system_info, current_turn),
90
+ content=content,
91
+ scoped_paths=scoped_paths,
92
+ git_status=git_status,
93
+ items=self._collect_items(
94
+ scoped_paths, file_contents, git_status, include_tokens
95
+ ),
96
+ agent_name=agent_name,
97
+ total_window=total_window,
98
+ system_prompt_tokens=system_prompt_tokens,
99
+ content_tokens=content_tokens,
100
+ )
101
+
102
+ def _resolve_scoped_paths(
103
+ self, context_files: Optional[Dict[str, Sequence[str]]]
104
+ ) -> tuple[Dict[str, List[str]], List[str]]:
105
+ """Resolves raw context files into scoped and deduplicated absolute paths."""
106
+ if not context_files:
107
+ raw_paths = self._file_system_manager.get_context_paths()
108
+ # Systemic Fix: Ensure default paths are also expanded recursively
109
+ all_paths = self._resolve_files_to_paths(raw_paths)
110
+ return {"Default": all_paths}, all_paths
111
+
112
+ # Backward compatibility: handle list of files
113
+ if isinstance(context_files, list):
114
+ context_files = {"Default": context_files}
115
+
116
+ scoped_paths: Dict[str, List[str]] = {}
117
+ all_resolved_paths: List[str] = []
118
+
119
+ for scope, files in context_files.items():
120
+ paths = self._resolve_files_to_paths(files)
121
+ scoped_paths[scope] = paths
122
+ for p in paths:
123
+ if p not in all_resolved_paths:
124
+ all_resolved_paths.append(p)
125
+
126
+ return scoped_paths, all_resolved_paths
127
+
128
+ def _resolve_files_to_paths(self, files: Sequence[str]) -> List[str]:
129
+ """
130
+ Nuanced Resolution: Distinguishes manifests (.context) from targets.
131
+ Detects and expands directories recursively.
132
+ Preserves original order while deduplicating results.
133
+ """
134
+ paths: List[str] = []
135
+ processed_manifests: set[str] = set()
136
+
137
+ for f in files:
138
+ self._resolve_recursive(f, paths, processed_manifests)
139
+
140
+ return paths
141
+
142
+ def _resolve_recursive(
143
+ self, f: str, paths: List[str], processed_manifests: set[str]
144
+ ) -> None:
145
+ """Helper to resolve a single path recursively."""
146
+ if self._is_url(f):
147
+ if f not in paths:
148
+ paths.append(f)
149
+ elif self._is_manifest(f):
150
+ self._expand_manifest(f, paths, processed_manifests)
151
+ elif self._file_system_manager.is_dir(f):
152
+ self._expand_directory(f, paths)
153
+ elif f not in paths:
154
+ paths.append(f)
155
+
156
+ def _expand_manifest(
157
+ self, f: str, paths: List[str], processed_manifests: set[str]
158
+ ) -> None:
159
+ """Expands a manifest and recursively resolves its contents."""
160
+ if f in processed_manifests:
161
+ return
162
+ processed_manifests.add(f)
163
+
164
+ # Expansion of manifests can return multiple paths
165
+ resolved = self._file_system_manager.resolve_paths_from_files([f])
166
+ for r in resolved:
167
+ self._resolve_recursive(r, paths, processed_manifests)
168
+
169
+ def _expand_directory(self, f: str, paths: List[str]) -> None:
170
+ """Expands a directory and adds its contents to the path list."""
171
+ resolved = self._file_system_manager.list_directory_recursive(f)
172
+ for r in resolved:
173
+ if r not in paths:
174
+ paths.append(r)
175
+
176
+ def _is_manifest(self, file_path: str) -> bool:
177
+ """Determines if a file path refers to a .context manifest."""
178
+ return (
179
+ file_path.endswith(".context")
180
+ or file_path.endswith("/context")
181
+ or file_path == "context"
182
+ )
183
+
184
+ def _is_url(self, path: str) -> bool:
185
+ """Determines if a path is a remote URL."""
186
+ return path.startswith("http://") or path.startswith("https://")
187
+
188
+ def _collect_items(
189
+ self,
190
+ scoped_paths: Dict[str, List[str]],
191
+ file_contents: Dict[str, Optional[str]],
192
+ git_status: Optional[str],
193
+ include_tokens: bool,
194
+ ) -> List[ContextItem]:
195
+ """
196
+ Orchestrates the assembly of ContextItem metadata DTOs.
197
+ Deduplicates by path, prioritizing non-Turn scopes (e.g. Session).
198
+ """
199
+ parsed_status = self._parse_git_status(git_status)
200
+ path_to_tokens = self._get_path_to_tokens(
201
+ scoped_paths, file_contents, include_tokens
202
+ )
203
+
204
+ # Deduplication map: path -> ContextItem
205
+ items_map: Dict[str, ContextItem] = {}
206
+
207
+ for scope, paths in scoped_paths.items():
208
+ for path in paths:
209
+ # Priority logic: Set if new path, or if we can upgrade a "Turn" scope to something else.
210
+ is_new = path not in items_map
211
+ can_upgrade = (
212
+ not is_new and items_map[path].scope == "Turn" and scope != "Turn"
213
+ )
214
+
215
+ if is_new or can_upgrade:
216
+ items_map[path] = ContextItem(
217
+ path=path,
218
+ token_count=path_to_tokens.get(path, 0),
219
+ git_status=parsed_status.get(path, ""),
220
+ scope=scope,
221
+ )
222
+
223
+ return list(items_map.values())
224
+
225
+ def _get_path_to_tokens(
226
+ self,
227
+ scoped_paths: Dict[str, List[str]],
228
+ file_contents: Dict[str, Optional[str]],
229
+ include_tokens: bool,
230
+ ) -> Dict[str, int]:
231
+ """Calculates token counts for all unique files in parallel."""
232
+ if not include_tokens:
233
+ return {}
234
+
235
+ unique_paths = list(set().union(*scoped_paths.values()))
236
+ if not unique_paths:
237
+ return {}
238
+
239
+ token_counts = {}
240
+
241
+ def get_count(path: str) -> tuple[str, int]:
242
+ content = file_contents.get(path) or ""
243
+ return path, self._llm_client.get_text_token_count(content)
244
+
245
+ # Parallelize to handle large repositories without stalling the UI.
246
+ # We use a ThreadPoolExecutor as token counting is often offloaded or involves latency.
247
+ # Disable parallelization in tests to avoid pyfakefs deadlocks.
248
+ import os
249
+
250
+ max_workers = 10 if not os.environ.get("TEDDY_TESTING") else 1
251
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
252
+ future_to_path = {
253
+ executor.submit(get_count, path): path for path in unique_paths
254
+ }
255
+ for future in concurrent.futures.as_completed(future_to_path):
256
+ path, count = future.result()
257
+ token_counts[path] = count
258
+
259
+ return token_counts
260
+
261
+ def _parse_git_status(self, git_status: Optional[str]) -> Dict[str, str]:
262
+ """Parses git status -s output into a map of path -> status code."""
263
+ if not git_status:
264
+ return {}
265
+
266
+ # Minimum length for "XY path" format
267
+ min_line_length = 4
268
+ status_map = {}
269
+ for line in git_status.splitlines():
270
+ if len(line) >= min_line_length:
271
+ code = line[:2].strip()
272
+ path = line[3:].strip()
273
+
274
+ # Guideline: Map ?? to U (Untracked)
275
+ if code == "??":
276
+ code = "U"
277
+
278
+ status_map[path] = code
279
+
280
+ return status_map
281
+
282
+ def _format_header(
283
+ self, system_info: Dict[str, str], current_turn: Optional[str] = None
284
+ ) -> str:
285
+ """Formats the header section of the context report."""
286
+ header_parts = [
287
+ "# Project Context",
288
+ "\n## System Information",
289
+ f"- **Current Date:** {system_info.get('current_date', 'N/A')}",
290
+ f"- **Current Time:** {system_info.get('current_time', 'N/A')}",
291
+ f"- **CWD:** {system_info.get('cwd', 'N/A')}",
292
+ f"- **OS:** {system_info.get('os_name', 'N/A')} {system_info.get('os_version', 'N/A')}".strip(),
293
+ f"- **Shell:** {system_info.get('shell', 'N/A')}",
294
+ f"- **Current Turn:** {current_turn or 'N/A'}",
295
+ ]
296
+ return "\n".join(header_parts)
297
+
298
+ def _format_content(
299
+ self,
300
+ repo_tree: str,
301
+ scoped_paths: Dict[str, List[str]],
302
+ file_contents: Dict[str, Optional[str]],
303
+ git_status: Optional[str] = None,
304
+ ) -> str:
305
+ """Formats the main content section of the context report."""
306
+ display_status = git_status
307
+ if git_status == "":
308
+ display_status = "nothing to commit, working tree clean"
309
+
310
+ # Gather all unique paths
311
+ all_paths: List[str] = []
312
+ for paths in scoped_paths.values():
313
+ for p in paths:
314
+ if p not in all_paths:
315
+ all_paths.append(p)
316
+
317
+ # Partition standard workspace files and session files
318
+ workspace_paths = [p for p in all_paths if not is_session_file_path(p)]
319
+ session_paths = [p for p in all_paths if is_session_file_path(p)]
320
+
321
+ content_parts = [
322
+ "\n## Git Status",
323
+ display_status if display_status is not None else "",
324
+ "\n## Project Structure",
325
+ f"```\n{repo_tree}\n```",
326
+ ]
327
+
328
+ # Format workspace files under ## Resource Contents
329
+ content_parts.extend(
330
+ self._format_workspace_contents(workspace_paths, file_contents)
331
+ )
332
+
333
+ # Prepend Session History section first (after header's System Information)
334
+ session_history_parts = self._format_session_history(
335
+ session_paths, file_contents
336
+ )
337
+ if session_history_parts:
338
+ content_parts = session_history_parts + content_parts
339
+
340
+ return "\n".join(content_parts)
341
+
342
+ def _format_workspace_contents(
343
+ self,
344
+ workspace_paths: List[str],
345
+ file_contents: Dict[str, Optional[str]],
346
+ ) -> List[str]:
347
+ """Formats the workspace contents section."""
348
+ if not workspace_paths:
349
+ return []
350
+
351
+ parts = ["\n## Resource Contents"]
352
+ for path in workspace_paths:
353
+ parts.append("\n---")
354
+ if self._is_url(path):
355
+ parts.append(f"### [{path}]({path})")
356
+ else:
357
+ parts.append(f"### [{path}](/{path})")
358
+ content = file_contents.get(path)
359
+ if content is not None:
360
+ lang = get_language_from_path(path)
361
+ fence = get_fence_for_content(content)
362
+ parts.append(f"{fence}{lang}\n{content}\n{fence}")
363
+ else:
364
+ parts.append("```\n--- FILE NOT FOUND ---\n```")
365
+ return parts
366
+
367
+ CACHE_FILENAME = ".web_cache.json"
368
+
369
+ def _load_web_cache(self, cache_dir: Optional[str]) -> Dict[str, str]:
370
+ """Load the web content cache from disk.
371
+
372
+ Returns an empty dict if:
373
+ - cache_dir is None (caching disabled)
374
+ - Cache file does not exist
375
+ - Cache file contains invalid JSON
376
+ - Cache file contains non-dict structure
377
+ - Cache file contains non-string values
378
+ """
379
+ if not cache_dir:
380
+ return {}
381
+ cache_path = Path(cache_dir) / self.CACHE_FILENAME
382
+ if not cache_path.exists():
383
+ return {}
384
+ try:
385
+ raw = cache_path.read_text(encoding="utf-8")
386
+ parsed = json.loads(raw)
387
+ if not isinstance(parsed, dict):
388
+ return {}
389
+ # Validate all values are strings
390
+ for k, v in parsed.items():
391
+ if not isinstance(k, str) or not isinstance(v, str):
392
+ return {}
393
+ return parsed
394
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError):
395
+ return {}
396
+
397
+ def _save_web_cache(self, cache_dir: str, cache: Dict[str, str]) -> None:
398
+ """Write the cache to disk atomically.
399
+
400
+ Creates cache_dir if it does not exist.
401
+ Writes to a .tmp file first, then atomically renames to .web_cache.json
402
+ using Path.replace(). This prevents partial writes from corrupting
403
+ the cache if the process crashes mid-write.
404
+ """
405
+ cache_dir_path = Path(cache_dir)
406
+ cache_dir_path.mkdir(parents=True, exist_ok=True)
407
+ target = cache_dir_path / self.CACHE_FILENAME
408
+ tmp = cache_dir_path / f"{self.CACHE_FILENAME}.tmp"
409
+ tmp.write_text(json.dumps(cache, ensure_ascii=False), encoding="utf-8")
410
+ tmp.replace(target)
411
+
412
+ def _format_session_history(
413
+ self,
414
+ session_paths: List[str],
415
+ file_contents: Dict[str, Optional[str]],
416
+ ) -> List[str]:
417
+ """Formats the session history section."""
418
+ if not session_paths:
419
+ return []
420
+
421
+ recognized_session_paths = [
422
+ p for p in session_paths if get_session_history_display_name(p) is not None
423
+ ]
424
+ if not recognized_session_paths:
425
+ return []
426
+
427
+ recognized_session_paths.sort(key=get_session_history_sort_key)
428
+ parts = ["\n## Session History"]
429
+ for path in recognized_session_paths:
430
+ disp_name = get_session_history_display_name(path)
431
+ content = file_contents.get(path) or ""
432
+ content_str = content.strip()
433
+ lang = get_language_from_path(path)
434
+ fence = get_fence_for_content(content_str)
435
+ parts.append(f"\n### {disp_name}")
436
+ parts.append(f"{fence}{lang}\n{content_str}\n{fence}")
437
+ return parts
@@ -0,0 +1,128 @@
1
+ from typing import List
2
+
3
+ from teddy_executor.core.domain.models import (
4
+ MultipleMatchesFoundError,
5
+ SearchTextNotFoundError,
6
+ )
7
+ from teddy_executor.core.domain.models.plan import DEFAULT_SIMILARITY_THRESHOLD
8
+ from teddy_executor.core.ports.inbound.edit_simulator import EditPair, IEditSimulator
9
+ from teddy_executor.core.services.validation_rules.edit_matcher import find_best_match
10
+
11
+
12
+ class EditSimulator(IEditSimulator):
13
+ """
14
+ Implements IEditSimulator by applying surgical edits to a string.
15
+ """
16
+
17
+ def _apply_single_edit(
18
+ self,
19
+ content: str,
20
+ find: str,
21
+ replace: str,
22
+ threshold: float = DEFAULT_SIMILARITY_THRESHOLD,
23
+ match_all: bool = False,
24
+ ) -> tuple[str, float]:
25
+ """
26
+ Applies a single find/replace operation to content with domain logic.
27
+ """
28
+ best_match, score, is_ambiguous, offset = find_best_match(
29
+ content, find, threshold
30
+ )
31
+
32
+ if is_ambiguous and not match_all:
33
+ count = content.count(find) if score == 1.0 else 2
34
+ hint = " Please provide a larger FIND block to uniquely identify the section, refactor the code to avoid duplication. Alternatively you can use the list item `Match All: true` to change all occurrences in the file at once."
35
+ raise MultipleMatchesFoundError(
36
+ message=f"Found {count} ambiguous occurrences of {find!r}. Aborting edit to prevent ambiguity.{hint}",
37
+ content=content,
38
+ )
39
+
40
+ if score < threshold:
41
+ raise SearchTextNotFoundError(
42
+ message=f"Search text {find!r} not found in file (Best Score: {score:.2f}, Threshold: {threshold:.2f}).",
43
+ content=content,
44
+ )
45
+
46
+ # Apply indentation offset to the replacement block
47
+ if offset != 0:
48
+ replace = self._apply_indent_offset(replace, offset)
49
+
50
+ # Align replacement newline with original match to prevent concatenation
51
+ final_replace = replace
52
+ if (
53
+ best_match.endswith("\n")
54
+ and not find.endswith("\n")
55
+ and not replace.endswith("\n")
56
+ and replace != ""
57
+ ):
58
+ # Detect original line ending to prevent "Git Noise"
59
+ terminator = "\r\n" if best_match.endswith("\r\n") else "\n"
60
+ final_replace += terminator
61
+
62
+ if replace == "" and not match_all:
63
+ # Newline cleanup logic for surgical deletions
64
+ if best_match.endswith("\n") and (best_match) in content:
65
+ return content.replace(best_match, "", 1), score
66
+ if "\n" + best_match in content:
67
+ return content.replace("\n" + best_match, "", 1), score
68
+
69
+ if match_all:
70
+ # Replaces all occurrences of the found block
71
+ return content.replace(best_match, final_replace), score
72
+ return content.replace(best_match, final_replace, 1), score
73
+
74
+ def _apply_indent_offset(self, replace_block: str, offset: int) -> str:
75
+ """Applies a constant indentation offset to every non-empty line."""
76
+ lines = replace_block.splitlines(keepends=True)
77
+ result = []
78
+ for line in lines:
79
+ stripped = line.lstrip()
80
+ if not stripped:
81
+ result.append(line)
82
+ elif offset > 0:
83
+ result.append(" " * offset + line)
84
+ elif offset < 0:
85
+ current_indent = len(line) - len(stripped)
86
+ to_remove = min(abs(offset), current_indent)
87
+ result.append(line[to_remove:])
88
+ else:
89
+ result.append(line)
90
+ return "".join(result)
91
+
92
+ def simulate_edits(
93
+ self,
94
+ content: str,
95
+ edits: List[EditPair],
96
+ threshold: float = DEFAULT_SIMILARITY_THRESHOLD,
97
+ match_all: bool = False,
98
+ ) -> tuple[str, list[float]]:
99
+ """
100
+ Applies each FIND/REPLACE pair in sequence.
101
+ """
102
+ current_content = content
103
+ all_scores = []
104
+
105
+ for edit in edits:
106
+ # Local match_all override from action params or global
107
+ do_match_all = edit.get("match_all", match_all)
108
+
109
+ if do_match_all:
110
+ current_content, score = self._apply_single_edit(
111
+ current_content,
112
+ edit["find"],
113
+ edit["replace"],
114
+ threshold,
115
+ match_all=True,
116
+ )
117
+ all_scores.append(score)
118
+ else:
119
+ current_content, score = self._apply_single_edit(
120
+ current_content,
121
+ edit["find"],
122
+ edit["replace"],
123
+ threshold,
124
+ match_all=False,
125
+ )
126
+ all_scores.append(score)
127
+
128
+ return current_content, all_scores