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,309 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ from typing import TYPE_CHECKING, Any, List, Optional
6
+
7
+ if TYPE_CHECKING:
8
+ from mistletoe.block_token import (
9
+ Document,
10
+ )
11
+
12
+ from teddy_executor.core.domain.models import ActionData, Plan, ActionType
13
+ from teddy_executor.core.ports.inbound.plan_parser import IPlanParser, InvalidPlanError
14
+ from teddy_executor.core.services.parser_infrastructure import (
15
+ H1_LEVEL,
16
+ H2_LEVEL,
17
+ _FencePreProcessor,
18
+ _PeekableStream,
19
+ get_child_text,
20
+ get_action_heading,
21
+ consume_content_until_next_action,
22
+ normalize_headings,
23
+ print_ast,
24
+ )
25
+ from teddy_executor.core.services.parser_reporting import (
26
+ format_structural_mismatch_msg,
27
+ validate_plan_structure,
28
+ )
29
+ from teddy_executor.core.services.parser_metadata import parse_plan_metadata
30
+ from teddy_executor.core.services.action_parser_strategies import (
31
+ parse_create_action,
32
+ parse_read_action,
33
+ )
34
+ from teddy_executor.core.services.action_parser_complex import (
35
+ parse_edit_action,
36
+ parse_execute_action,
37
+ parse_research_action,
38
+ parse_message_action,
39
+ )
40
+
41
+
42
+ class MarkdownPlanParser(IPlanParser):
43
+ """
44
+ A service that parses a Markdown plan string into a `Plan` domain object using a
45
+ single-pass AST stream.
46
+ """
47
+
48
+ def __init__(self):
49
+ self._preprocessor = _FencePreProcessor()
50
+ self._valid_actions = {action.value for action in ActionType}
51
+ self._dispatch_map = {
52
+ "CREATE": parse_create_action,
53
+ "READ": parse_read_action,
54
+ "EDIT": lambda s, node=None: parse_edit_action(
55
+ s, self._valid_actions, node=node
56
+ ),
57
+ "EXECUTE": parse_execute_action,
58
+ "RESEARCH": lambda s, node=None: parse_research_action(
59
+ s, self._valid_actions, node=node
60
+ ),
61
+ }
62
+
63
+ def parse(self, plan_content: str, plan_path: Optional[str] = None) -> Plan:
64
+ """
65
+ Parses the specified Markdown plan string into a structured Plan object.
66
+ """
67
+ from mistletoe.block_token import (
68
+ Document,
69
+ )
70
+
71
+ # Trim trailing whitespace to prevent mistletoe from
72
+ # interpreting trailing indentation as an unexpected code block.
73
+ # We keep leading whitespace for potential Markdown significance (though rare at top-level).
74
+ clean_content = plan_content.rstrip()
75
+
76
+ if not clean_content:
77
+ raise InvalidPlanError("Plan content cannot be empty.")
78
+
79
+ # Strip preamble (text before the first # heading at start of a line)
80
+ # Use MULTILINE so ^ matches start of any line. Allow optional leading whitespace
81
+ # before # because Markdown permits up to 3 spaces before heading markers.
82
+ h1_match = re.search(r"^[ \t]*#", clean_content, re.MULTILINE)
83
+ if h1_match and h1_match.start() > 0:
84
+ clean_content = clean_content[h1_match.start() :]
85
+
86
+ # Normalize H1 heading on the first line (e.g., #Title -> # Title)
87
+ # This runs after preamble stripping so it always targets the heading line
88
+ clean_content = normalize_headings(clean_content)
89
+
90
+ processed_content = self._preprocessor.process(clean_content)
91
+ doc = Document(processed_content)
92
+
93
+ if os.environ.get("TEDDY_DEBUG"):
94
+ print_ast(doc)
95
+
96
+ stream = _PeekableStream(iter(doc.children or []))
97
+
98
+ try:
99
+ title, rationale, metadata, section_heading = self._parse_strict_top_level(
100
+ stream, doc
101
+ )
102
+
103
+ self._validate_mutual_exclusivity(doc)
104
+
105
+ actions = self._parse_section_content(
106
+ stream, clean_content, section_heading, doc
107
+ )
108
+
109
+ is_session = False
110
+ if plan_path:
111
+ normalized_path = plan_path.replace("\\", "/").lower()
112
+ is_session = ".teddy/sessions/" in normalized_path
113
+
114
+ plan = Plan(
115
+ title=title,
116
+ rationale=rationale,
117
+ actions=actions,
118
+ metadata=metadata,
119
+ source_doc=doc,
120
+ is_session=is_session,
121
+ plan_path=plan_path,
122
+ raw_content=clean_content,
123
+ )
124
+
125
+ # Write corrected content back to source file if it came from a session file path
126
+ if plan_path and is_session:
127
+ from pathlib import Path
128
+
129
+ path_obj = Path(plan_path)
130
+ try:
131
+ current_disk = path_obj.read_text(encoding="utf-8")
132
+ except Exception:
133
+ current_disk = None
134
+ if current_disk is not None and current_disk.rstrip() != clean_content:
135
+ path_obj.write_text(clean_content, encoding="utf-8")
136
+
137
+ return plan
138
+ except InvalidPlanError as e:
139
+ if "### Expected Response Structure (MRP) " in str(e):
140
+ raise e
141
+
142
+ # Re-format the error using the shared infrastructure to always include AST
143
+ e_nodes = getattr(e, "offending_nodes", [])
144
+ rich_msg = format_structural_mismatch_msg(
145
+ doc, str(e).splitlines()[0], -1, e_nodes
146
+ )
147
+ raise InvalidPlanError(rich_msg, offending_nodes=e_nodes) from e
148
+
149
+ def _raise_structural_error(
150
+ self, doc: Document, expected_name: str, mismatch_idx: int, actual_node: Any
151
+ ):
152
+ """Constructs and raises a detailed structural validation error."""
153
+ offending_nodes = [actual_node] if actual_node else []
154
+ raise InvalidPlanError(
155
+ self._format_structural_mismatch_msg(
156
+ doc, expected_name, mismatch_idx, actual_node
157
+ ),
158
+ offending_nodes=offending_nodes,
159
+ )
160
+
161
+ def _format_structural_mismatch_msg(
162
+ self, doc: Document, expected: str, mismatch_idx: int, actual_node: Any
163
+ ) -> str:
164
+ """Wrapper for infrastructure helper to maintain internal API for tests."""
165
+ offending_nodes = (
166
+ actual_node if isinstance(actual_node, list) else [actual_node]
167
+ )
168
+ return format_structural_mismatch_msg(
169
+ doc, expected, mismatch_idx, offending_nodes
170
+ )
171
+
172
+ def _consume_mandatory_node(
173
+ self, stream: _PeekableStream, doc: Document, idx: int, expected: str, predicate
174
+ ) -> Any:
175
+ node = stream.peek()
176
+ if not node or not predicate(node):
177
+ self._raise_structural_error(doc, expected, idx, node)
178
+ return stream.next()
179
+
180
+ def _parse_strict_top_level(
181
+ self, stream: _PeekableStream, doc: Document
182
+ ) -> tuple[str, str, dict[str, str], Any]:
183
+ from mistletoe.block_token import Heading
184
+
185
+ # 0: Find H1 Title. Must be at index 0 per Rule 3.1.
186
+ node = stream.peek()
187
+ start_idx = 0
188
+
189
+ if not node or not (isinstance(node, Heading) and node.level == H1_LEVEL):
190
+ offending_nodes = [node] if node else []
191
+ rich_msg = format_structural_mismatch_msg(
192
+ doc, "a Level 1 Heading (Title)", 0, offending_nodes
193
+ )
194
+ raise InvalidPlanError(rich_msg, offending_nodes=offending_nodes)
195
+
196
+ title = get_child_text(node).strip()
197
+
198
+ validate_plan_structure(doc, start_idx)
199
+
200
+ # If we got here, the structure is correct. Consume nodes and extract data.
201
+ stream.next() # Title (already used)
202
+ metadata_list_node = stream.next()
203
+ if not metadata_list_node:
204
+ raise InvalidPlanError(
205
+ "Plan parsing failed: Expected metadata list missing."
206
+ )
207
+
208
+ metadata = parse_plan_metadata(metadata_list_node)
209
+
210
+ stream.next() # H2 Rationale
211
+ rationale_node = stream.next()
212
+ rationale = get_child_text(rationale_node).strip()
213
+ section_heading = stream.next() # H2 Action Plan or Message
214
+
215
+ return title, rationale, metadata, section_heading
216
+
217
+ def _validate_mutual_exclusivity(self, doc: "Document") -> None:
218
+ """Validates that the document does not contain both ## Action Plan and ## Message."""
219
+ from mistletoe.block_token import Heading
220
+
221
+ doc_children = doc.children or []
222
+ h2_headings = [
223
+ n for n in doc_children if isinstance(n, Heading) and n.level == H2_LEVEL
224
+ ]
225
+ h2_texts = [get_child_text(h) for h in h2_headings]
226
+ if "Action Plan" in h2_texts and "Message" in h2_texts:
227
+ raise InvalidPlanError(
228
+ "Plan cannot contain both '## Action Plan' and '## Message'. Mutual exclusivity is required."
229
+ )
230
+
231
+ def _parse_section_content(
232
+ self,
233
+ stream: _PeekableStream,
234
+ clean_content: str,
235
+ section_heading: Any,
236
+ doc: Document,
237
+ ) -> List[ActionData]:
238
+ """Parses the content of either a ## Message or ## Action Plan section."""
239
+ section_name = get_child_text(section_heading).strip()
240
+ if "Message" in section_name:
241
+ raw_content = None
242
+ start_line = getattr(section_heading, "line_number", None)
243
+ if start_line is not None and start_line > 0:
244
+ lines = clean_content.splitlines(keepends=True)
245
+ if start_line < len(lines):
246
+ raw_content = "".join(lines[start_line:]).lstrip("\n")
247
+ actions = [
248
+ parse_message_action(
249
+ stream, node=section_heading, raw_content=raw_content
250
+ )
251
+ ]
252
+ else:
253
+ actions = self._parse_actions(stream, doc)
254
+ return actions
255
+
256
+ def _parse_actions(
257
+ self, stream: _PeekableStream, doc: Document
258
+ ) -> List[ActionData]:
259
+ from mistletoe.block_token import BlockCode, CodeFence, ThematicBreak
260
+
261
+ actions: List[ActionData] = []
262
+ # 'Action Plan' heading is already consumed by _parse_strict_top_level.
263
+
264
+ # Parse all subsequent actions
265
+ while stream.has_next():
266
+ node = stream.peek()
267
+ action_heading = get_action_heading(node, self._valid_actions)
268
+
269
+ if not action_heading:
270
+ # Skip code blocks and thematic breaks that can appear between
271
+ # action blocks due to formatting or trailing content.
272
+ if isinstance(node, (BlockCode, CodeFence, ThematicBreak)):
273
+ stream.next()
274
+ continue
275
+
276
+ # Accumulate offending node and raise structural error
277
+ offending_nodes = consume_content_until_next_action(
278
+ stream, self._valid_actions
279
+ )
280
+ raise InvalidPlanError(
281
+ format_structural_mismatch_msg(
282
+ doc, "a Level 3 Action Heading", -1, offending_nodes
283
+ ),
284
+ offending_nodes=offending_nodes,
285
+ )
286
+
287
+ stream.next() # Consume action heading
288
+ action_type_str = get_child_text(action_heading).strip().replace("`", "")
289
+
290
+ # Guard: MESSAGE under ## Action Plan must produce a clear mutual exclusivity error
291
+ if action_type_str == "MESSAGE":
292
+ raise InvalidPlanError(
293
+ "MESSAGE action is not allowed under '## Action Plan'. "
294
+ "Use '## Message' section instead. Mutual exclusivity is required.",
295
+ offending_nodes=[action_heading],
296
+ )
297
+
298
+ if action_type_str not in self._dispatch_map:
299
+ raise InvalidPlanError(
300
+ f"Unknown action type: {action_type_str}",
301
+ offending_nodes=[action_heading],
302
+ )
303
+
304
+ parse_method = self._dispatch_map[action_type_str]
305
+ actions.append(parse_method(stream, node=action_heading))
306
+
307
+ return actions
308
+
309
+ # Structural formatting logic moved to parser_infrastructure.py
@@ -0,0 +1,143 @@
1
+ import os
2
+ from datetime import timezone
3
+ from typing import Any
4
+
5
+ from teddy_executor.core.domain.models import ExecutionReport
6
+ from teddy_executor.core.ports.outbound.markdown_report_formatter import (
7
+ IMarkdownReportFormatter,
8
+ )
9
+ from teddy_executor.core.utils.markdown import (
10
+ get_fence_for_content,
11
+ get_language_from_path,
12
+ )
13
+
14
+
15
+ class MarkdownReportFormatter(IMarkdownReportFormatter):
16
+ """
17
+ Implements IMarkdownReportFormatter using the Jinja2 template engine.
18
+ """
19
+
20
+ _cached_env = None
21
+ _cached_template = None
22
+
23
+ @classmethod
24
+ def _reset_singleton(cls):
25
+ """Internal helper for test isolation."""
26
+ cls._cached_env = None
27
+ cls._cached_template = None
28
+
29
+ def __init__(self):
30
+ from jinja2 import Environment, PackageLoader
31
+
32
+ if MarkdownReportFormatter._cached_env is None:
33
+ env = Environment(
34
+ loader=PackageLoader("teddy_executor.core.services", "templates"),
35
+ trim_blocks=True,
36
+ lstrip_blocks=True,
37
+ autoescape=False, # nosec B701
38
+ )
39
+ env.filters["basename"] = os.path.basename
40
+ env.filters["fence"] = get_fence_for_content
41
+ env.filters["language_from_path"] = get_language_from_path
42
+
43
+ MarkdownReportFormatter._cached_env = env
44
+ MarkdownReportFormatter._cached_template = env.get_template(
45
+ "execution_report.md.j2"
46
+ )
47
+
48
+ self.env = MarkdownReportFormatter._cached_env
49
+ self.template = MarkdownReportFormatter._cached_template
50
+
51
+ def _prepare_context(self, report: ExecutionReport) -> dict[str, Any]:
52
+ """Prepares the report data for rendering."""
53
+
54
+ def format_datetime(dt):
55
+ if not dt:
56
+ return ""
57
+ if dt.tzinfo is None:
58
+ dt = dt.replace(tzinfo=timezone.utc)
59
+ return dt.isoformat()
60
+
61
+ plan_title: str = "Untitled Plan"
62
+ if hasattr(report, "plan_title"):
63
+ val = getattr(report, "plan_title")
64
+ plan_title = str(val) if val is not None else "Untitled Plan"
65
+ elif isinstance(report, dict):
66
+ plan_title = str(report.get("plan_title", "Untitled Plan"))
67
+
68
+ is_session = False
69
+ if hasattr(report, "is_session"):
70
+ is_session = bool(getattr(report, "is_session"))
71
+ elif isinstance(report, dict):
72
+ is_session = bool(report.get("is_session", False))
73
+
74
+ return {
75
+ "report": report,
76
+ "is_session": is_session,
77
+ "plan_title": plan_title,
78
+ "format_datetime": format_datetime,
79
+ }
80
+
81
+ def format(self, report: ExecutionReport) -> str:
82
+ """Renders the execution report to a Markdown string."""
83
+ from teddy_executor.core.utils.serialization import (
84
+ scrub_dict_for_serialization,
85
+ )
86
+
87
+ # 1. Prepare context with real objects to support attribute access in Python
88
+ context = self._prepare_context(report)
89
+
90
+ # 2. Scrub the report data specifically to neutralize mocks for Jinja2
91
+ report_data = (
92
+ report.__dict__
93
+ if hasattr(report, "__dict__")
94
+ else (report if isinstance(report, dict) else {})
95
+ )
96
+ context["report"] = scrub_dict_for_serialization(report_data)
97
+
98
+ # 3. Render with scrubbed data but real functions
99
+ rendered = self.template.render(context)
100
+
101
+ # Post-process for whitespace sanitization
102
+ lines = [line.rstrip() for line in rendered.splitlines()]
103
+
104
+ sanitized_lines = []
105
+ in_fence = False
106
+ consecutive_blanks = 0
107
+
108
+ for line in lines:
109
+ # Track code block state
110
+ if line.strip().startswith("```"):
111
+ in_fence = not in_fence
112
+
113
+ if in_fence:
114
+ # Inside code block: preserve all whitespace and newlines
115
+ sanitized_lines.append(line)
116
+ consecutive_blanks = 0
117
+ # Outside code block: apply sanitization rules
118
+ elif not line:
119
+ consecutive_blanks += 1
120
+ # Only allow one consecutive blank line (max 2 newlines)
121
+ if consecutive_blanks <= 1:
122
+ sanitized_lines.append(line)
123
+ else:
124
+ # If the line starts with a bullet point, prevent a blank line before it
125
+ # unless it's the very first bullet in a list after a header.
126
+ # This ensures density for list items.
127
+ if (
128
+ line.strip().startswith("- ")
129
+ and sanitized_lines
130
+ and not sanitized_lines[-1].strip()
131
+ ):
132
+ # If the previous line was blank and we are starting a bullet,
133
+ # check if the line before THAT was also a bullet.
134
+ if len(sanitized_lines) > 1 and sanitized_lines[
135
+ -2
136
+ ].strip().startswith("- "):
137
+ sanitized_lines.pop() # Remove the blank line between bullets
138
+
139
+ consecutive_blanks = 0
140
+ sanitized_lines.append(line)
141
+
142
+ sanitized = "\n".join(sanitized_lines).strip()
143
+ return sanitized
@@ -0,0 +1,222 @@
1
+ import os
2
+ import re
3
+ from typing import Any, List, Optional, Iterator, TYPE_CHECKING
4
+
5
+
6
+ # Insert a space after `#` on the first line if missing (e.g., `#Title` -> `# Title`)
7
+ # Only normalizes the first line to avoid corrupting code fences or shebangs.
8
+ def normalize_headings(content: str) -> str:
9
+ """Insert a space after `#` if missing on the first line (the H1 title)."""
10
+ first_newline = content.find("\n")
11
+ if first_newline == -1:
12
+ first_line = content
13
+ rest = ""
14
+ else:
15
+ first_line = content[:first_newline]
16
+ rest = content[first_newline:]
17
+ if re.match(r"^#[^ #\t\n]", first_line):
18
+ first_line = "# " + first_line[1:]
19
+ return first_line + rest
20
+
21
+
22
+ if TYPE_CHECKING:
23
+ from mistletoe.block_token import Heading
24
+
25
+ # Constants for Markdown structure
26
+ H1_LEVEL = 1
27
+ H2_LEVEL = 2
28
+ H3_LEVEL = 3
29
+
30
+ # Constant for parsing key-value pairs
31
+ EXPECTED_KV_PARTS = 2
32
+
33
+
34
+ class _FencePreProcessor:
35
+ """
36
+ A utility to pre-process raw LLM Markdown output to ensure all code fences are valid
37
+ before parsing. This is a crucial safety net.
38
+ """
39
+
40
+ def process(self, content: str) -> str:
41
+ """
42
+ Pre-process raw Markdown content to normalize code fences.
43
+
44
+ Currently handles:
45
+ - Stripping trailing non-whitespace content on fence lines with 6+
46
+ consecutive backticks or tildes (e.g., ``~~~~~~ trailing text`` → ``~~~~~~``).
47
+ """
48
+ lines = content.split("\n")
49
+ result = []
50
+ # Pattern: optional leading whitespace, then 6+ consecutive pure tildes
51
+ # OR 6+ consecutive pure backticks, then any trailing content.
52
+ pattern = re.compile(r"^(\s*)(\~{6,}|\`{6,})(.*)$")
53
+
54
+ for line in lines:
55
+ match = pattern.match(line)
56
+ if match:
57
+ trailing = match.group(3)
58
+ # Only strip trailing content if it does NOT contain any backtick
59
+ # or tilde. This prevents corrupting lines like
60
+ # "~~~~~~` trailing" where fence characters appear in content.
61
+ if trailing is not None and trailing.strip():
62
+ if not any(c in trailing for c in ("`", "~")):
63
+ line = match.group(1) + match.group(2)
64
+ # If trailing is empty/whitespace or contains fence chars,
65
+ # keep original line unchanged.
66
+ result.append(line)
67
+
68
+ return "\n".join(result)
69
+
70
+
71
+ class _PeekableStream:
72
+ """A wrapper for an iterator to allow peeking at the next item."""
73
+
74
+ def __init__(self, iterator: Iterator[Any]):
75
+ self._iterator = iterator
76
+ self._next_item: Optional[Any] = None
77
+ self._fetch_next()
78
+
79
+ def _fetch_next(self):
80
+ try:
81
+ self._next_item = next(self._iterator)
82
+ except StopIteration:
83
+ self._next_item = None
84
+
85
+ def has_next(self) -> bool:
86
+ return self._next_item is not None
87
+
88
+ def peek(self) -> Optional[Any]:
89
+ return self._next_item
90
+
91
+ def next(self) -> Optional[Any]:
92
+ current_item = self._next_item
93
+ if current_item is not None:
94
+ self._fetch_next()
95
+ return current_item
96
+
97
+
98
+ def normalize_path(path: str) -> str:
99
+ return path.replace("\\", "/")
100
+
101
+
102
+ def normalize_link_target(target: str) -> str:
103
+ if target.startswith(("http://", "https://")):
104
+ return target
105
+ is_abs = os.path.isabs(target)
106
+ is_likely_true_absolute = False
107
+ if os.name == "nt":
108
+ has_drive, _ = os.path.splitdrive(target)
109
+ if is_abs and has_drive:
110
+ is_likely_true_absolute = True
111
+ elif os.name == "posix" and is_abs:
112
+ common_roots = ("/tmp", "/etc", "/home", "/var", "/usr", "/root") # nosec B108
113
+ if target.startswith(common_roots):
114
+ is_likely_true_absolute = True
115
+ if target.startswith("/") and not is_likely_true_absolute:
116
+ return target.lstrip("/")
117
+ return target
118
+
119
+
120
+ def find_node_in_tree(node: Any, node_type: type) -> Optional[Any]:
121
+ if isinstance(node, node_type):
122
+ return node
123
+ if hasattr(node, "children") and node.children is not None:
124
+ for child in node.children:
125
+ found = find_node_in_tree(child, node_type)
126
+ if found:
127
+ return found
128
+ return None
129
+
130
+
131
+ def get_child_text(node: Any) -> str:
132
+ if hasattr(node, "children") and node.children is not None:
133
+ return "".join([get_child_text(child) for child in node.children])
134
+ return getattr(node, "content", "")
135
+
136
+
137
+ def get_action_heading(node: Any, valid_actions: set[str]) -> "Optional[Heading]":
138
+ """Checks if a node is a valid H3 action heading."""
139
+ from mistletoe.block_token import Heading
140
+ from mistletoe.span_token import InlineCode
141
+
142
+ if isinstance(node, Heading) and node.level == H3_LEVEL:
143
+ text = get_child_text(node).strip()
144
+ potential_type = text.split(":")[0].strip().replace("`", "")
145
+ if potential_type in valid_actions:
146
+ return node
147
+ # Allow unknown actions if they are formatted like `ACTION` to fail later
148
+ children = list(node.children) if node.children else []
149
+ if children and isinstance(children[0], InlineCode):
150
+ return node
151
+ return None
152
+
153
+
154
+ def consume_content_until_next_action(
155
+ stream: _PeekableStream, valid_actions: set[str]
156
+ ) -> List[Any]:
157
+ """Consumes nodes from the stream until the next H3 action heading or H1/H2."""
158
+ from mistletoe.block_token import Heading
159
+
160
+ content_nodes = []
161
+ while stream.has_next():
162
+ node = stream.peek()
163
+ if isinstance(node, Heading):
164
+ if node.level <= H2_LEVEL:
165
+ break
166
+ if get_action_heading(node, valid_actions):
167
+ break
168
+ content_nodes.append(stream.next())
169
+ return content_nodes
170
+
171
+
172
+ def print_ast(token: Any, indent: int = 0):
173
+ """Recursively prints the AST in a readable format for debugging."""
174
+ prefix = " " * indent
175
+ print(f"{prefix}- {type(token).__name__}")
176
+
177
+ content_attr = getattr(token, "content", None)
178
+ if content_attr is not None:
179
+ first_line = (
180
+ str(content_attr).splitlines()[0]
181
+ if "\n" in str(content_attr)
182
+ else str(content_attr)
183
+ )
184
+ print(f'{prefix} Content: "{first_line[:80]}"')
185
+
186
+ children_attr = getattr(token, "children", None)
187
+ if children_attr is not None:
188
+ for child in children_attr:
189
+ print_ast(child, indent + 1)
190
+
191
+
192
+ def translate_setup_commands(
193
+ setup_str: str,
194
+ initial_cwd: Optional[str] = None,
195
+ initial_env: Optional[dict[str, str]] = None,
196
+ ) -> tuple[Optional[str], Optional[dict[str, str]]]:
197
+ """
198
+ Translates a chained setup string (e.g. 'cd dir && export FOO=bar')
199
+ into cwd and env parameters.
200
+ """
201
+ cwd = initial_cwd
202
+ env = initial_env
203
+
204
+ parts = [p.strip() for p in setup_str.split("&&")]
205
+ for part in parts:
206
+ if part.startswith("cd "):
207
+ cwd = part[3:].strip()
208
+ elif part.startswith("export "):
209
+ if env is None:
210
+ env = {}
211
+ kv_part = part[7:].strip()
212
+ if "=" in kv_part:
213
+ key, value = kv_part.split("=", 1)
214
+ key = key.strip()
215
+ value = value.strip()
216
+ if (value.startswith('"') and value.endswith('"')) or (
217
+ value.startswith("'") and value.endswith("'")
218
+ ):
219
+ value = value[1:-1]
220
+ env[key] = value
221
+
222
+ return cwd, env