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,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
|