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,57 @@
|
|
|
1
|
+
import difflib
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def generate_unified_diff(
|
|
5
|
+
before: str, after: str, filename: str, from_label: str = "a", to_label: str = "b"
|
|
6
|
+
) -> str:
|
|
7
|
+
"""
|
|
8
|
+
Generates a unified diff between two strings.
|
|
9
|
+
"""
|
|
10
|
+
diff_generator = difflib.unified_diff(
|
|
11
|
+
before.splitlines(keepends=True),
|
|
12
|
+
after.splitlines(keepends=True),
|
|
13
|
+
fromfile=f"{from_label}/{filename}",
|
|
14
|
+
tofile=f"{to_label}/{filename}",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
diff_lines = []
|
|
18
|
+
for line in diff_generator:
|
|
19
|
+
diff_lines.append(line)
|
|
20
|
+
if not line.endswith("\n"):
|
|
21
|
+
diff_lines.append("\n")
|
|
22
|
+
|
|
23
|
+
return "".join(diff_lines).rstrip()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def generate_character_diff(before: str, after: str, context: int = 2) -> str:
|
|
27
|
+
"""
|
|
28
|
+
Generates a character-level diff using ndiff with hunk-based filtering.
|
|
29
|
+
Shows changed lines, their context, and joins hunks with '...'.
|
|
30
|
+
"""
|
|
31
|
+
before_lines = before.splitlines(keepends=True)
|
|
32
|
+
after_lines = after.splitlines(keepends=True)
|
|
33
|
+
diff = list(difflib.ndiff(before_lines, after_lines))
|
|
34
|
+
|
|
35
|
+
# Identify lines that must be included (changes)
|
|
36
|
+
must_include = [i for i, line in enumerate(diff) if line[0] in ("-", "+", "?")]
|
|
37
|
+
if not must_include:
|
|
38
|
+
return ""
|
|
39
|
+
|
|
40
|
+
# Expand to include context around each change
|
|
41
|
+
included_indices = set()
|
|
42
|
+
for idx in must_include:
|
|
43
|
+
for i in range(max(0, idx - context), min(len(diff), idx + context + 1)):
|
|
44
|
+
included_indices.add(i)
|
|
45
|
+
|
|
46
|
+
# Build hunks from sorted indices
|
|
47
|
+
sorted_indices = sorted(list(included_indices))
|
|
48
|
+
res_lines = []
|
|
49
|
+
last_idx = -1
|
|
50
|
+
|
|
51
|
+
for idx in sorted_indices:
|
|
52
|
+
if last_idx != -1 and idx > last_idx + 1:
|
|
53
|
+
res_lines.append("...")
|
|
54
|
+
res_lines.append(diff[idx].rstrip("\n\r"))
|
|
55
|
+
last_idx = idx
|
|
56
|
+
|
|
57
|
+
return "\n".join(res_lines)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Optional, TextIO
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class _TeeWriter:
|
|
8
|
+
def __init__(self, original: TextIO, log_file: TextIO):
|
|
9
|
+
self._original = original
|
|
10
|
+
self._log_file = log_file
|
|
11
|
+
|
|
12
|
+
# ANSI escape sequence pattern (e.g., \x1b[31m, \x1b[1;33m, \x1b[A)
|
|
13
|
+
_ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]")
|
|
14
|
+
|
|
15
|
+
def write(self, text: str) -> None:
|
|
16
|
+
# Terminal always gets raw text (colours preserved)
|
|
17
|
+
self._original.write(text)
|
|
18
|
+
self._original.flush()
|
|
19
|
+
# Log file gets cleaned text (ANSI stripped)
|
|
20
|
+
clean = self._ANSI_ESCAPE.sub("", text)
|
|
21
|
+
self._log_file.write(clean)
|
|
22
|
+
self._log_file.flush()
|
|
23
|
+
|
|
24
|
+
def flush(self) -> None:
|
|
25
|
+
self._original.flush()
|
|
26
|
+
try:
|
|
27
|
+
self._log_file.flush()
|
|
28
|
+
except OSError:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
def isatty(self) -> bool:
|
|
32
|
+
return self._original.isatty()
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def encoding(self) -> str:
|
|
36
|
+
return self._original.encoding or "utf-8"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Tee:
|
|
40
|
+
def __init__(self, log_file: TextIO):
|
|
41
|
+
self._log_file: Optional[TextIO] = log_file
|
|
42
|
+
self._original_stdout: Optional[TextIO] = None
|
|
43
|
+
self._original_stderr: Optional[TextIO] = None
|
|
44
|
+
|
|
45
|
+
def __enter__(self) -> "Tee":
|
|
46
|
+
self._original_stdout = sys.stdout
|
|
47
|
+
self._original_stderr = sys.stderr
|
|
48
|
+
if self._log_file is None:
|
|
49
|
+
return self
|
|
50
|
+
sys.stdout = _TeeWriter(self._original_stdout, self._log_file)
|
|
51
|
+
sys.stderr = _TeeWriter(self._original_stderr, self._log_file)
|
|
52
|
+
|
|
53
|
+
# Fix for bug 22: Replace root logger handlers with new ones that use
|
|
54
|
+
# the current sys.stderr (the Tee proxy). This robustly ensures logging
|
|
55
|
+
# output flows through the Tee, regardless of how handlers were added.
|
|
56
|
+
old_handlers = list(logging.root.handlers)
|
|
57
|
+
for h in old_handlers:
|
|
58
|
+
logging.root.removeHandler(h)
|
|
59
|
+
h.close()
|
|
60
|
+
new_handler = logging.StreamHandler(sys.stderr)
|
|
61
|
+
new_handler.setFormatter(logging.Formatter("%(message)s"))
|
|
62
|
+
logging.root.addHandler(new_handler)
|
|
63
|
+
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
67
|
+
if self._original_stdout is not None:
|
|
68
|
+
sys.stdout = self._original_stdout
|
|
69
|
+
if self._original_stderr is not None:
|
|
70
|
+
sys.stderr = self._original_stderr
|
|
71
|
+
if self._log_file is not None:
|
|
72
|
+
try:
|
|
73
|
+
self._log_file.close()
|
|
74
|
+
except OSError:
|
|
75
|
+
pass
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def get_language_from_path(path: str) -> str:
|
|
6
|
+
"""
|
|
7
|
+
Determines the appropriate markdown code block language identifier based on
|
|
8
|
+
a file path. Falls back to the extension itself, or 'text' if no
|
|
9
|
+
extension exists.
|
|
10
|
+
"""
|
|
11
|
+
_, ext = os.path.splitext(path)
|
|
12
|
+
if not ext:
|
|
13
|
+
return "text"
|
|
14
|
+
|
|
15
|
+
ext = ext.lower().lstrip(".")
|
|
16
|
+
|
|
17
|
+
# Common mappings where extension doesn't match language identifier exactly
|
|
18
|
+
extension_map = {
|
|
19
|
+
"py": "python",
|
|
20
|
+
"js": "javascript",
|
|
21
|
+
"jsx": "jsx",
|
|
22
|
+
"ts": "typescript",
|
|
23
|
+
"tsx": "tsx",
|
|
24
|
+
"md": "markdown",
|
|
25
|
+
"sh": "shell",
|
|
26
|
+
"bash": "shell",
|
|
27
|
+
"zsh": "shell",
|
|
28
|
+
"yml": "yaml",
|
|
29
|
+
"txt": "text",
|
|
30
|
+
"ps1": "powershell",
|
|
31
|
+
"cs": "csharp",
|
|
32
|
+
"tf": "terraform",
|
|
33
|
+
"ini": "ini",
|
|
34
|
+
"cfg": "ini",
|
|
35
|
+
"conf": "ini",
|
|
36
|
+
"h": "c",
|
|
37
|
+
"hpp": "cpp",
|
|
38
|
+
"cxx": "cpp",
|
|
39
|
+
"cc": "cpp",
|
|
40
|
+
"rb": "ruby",
|
|
41
|
+
"rs": "rust",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return extension_map.get(ext, ext)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def extract_markdown_section(content: str, header: str, level: int = 2) -> str | None:
|
|
48
|
+
"""
|
|
49
|
+
Extracts the content of a specific Markdown section.
|
|
50
|
+
Returns None if the section is not found or empty.
|
|
51
|
+
"""
|
|
52
|
+
prefix = "#" * level
|
|
53
|
+
# Split by the specified header level
|
|
54
|
+
sections = re.split(rf"(?m)^{prefix}\s+", content)
|
|
55
|
+
for section in sections:
|
|
56
|
+
if section.startswith(header):
|
|
57
|
+
lines = section.splitlines()
|
|
58
|
+
if len(lines) > 1:
|
|
59
|
+
body = "\n".join(lines[1:]).strip()
|
|
60
|
+
return body if body else None
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_fence_for_content(content: str) -> str:
|
|
65
|
+
"""
|
|
66
|
+
Returns a markdown code fence string (e.g., "```") that is safe to use
|
|
67
|
+
for enclosing the given content.
|
|
68
|
+
|
|
69
|
+
The length of the fence will be at least 3, and strictly greater than
|
|
70
|
+
the longest sequence of backticks found in the content.
|
|
71
|
+
"""
|
|
72
|
+
if not content:
|
|
73
|
+
return "```"
|
|
74
|
+
|
|
75
|
+
# Find all sequences of backticks
|
|
76
|
+
backtick_sequences = re.findall(r"`+", content)
|
|
77
|
+
|
|
78
|
+
max_backticks = 0
|
|
79
|
+
if backtick_sequences:
|
|
80
|
+
max_backticks = max(len(seq) for seq in backtick_sequences)
|
|
81
|
+
|
|
82
|
+
# Ensure fence is at least 3, and at least one longer than max found
|
|
83
|
+
fence_length = max(3, max_backticks + 1)
|
|
84
|
+
|
|
85
|
+
return "`" * fence_length
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_session_history_display_name(path: str) -> str | None:
|
|
89
|
+
"""
|
|
90
|
+
Returns human-readable display name if it's a recognized session history file.
|
|
91
|
+
"""
|
|
92
|
+
clean_path = path.lstrip("./")
|
|
93
|
+
if "initial_request.md" in clean_path:
|
|
94
|
+
return "Initial Request"
|
|
95
|
+
plan_match = re.search(r"sessions/[^/]+/(\d+)/plan.md$", clean_path)
|
|
96
|
+
if plan_match:
|
|
97
|
+
return f"Turn {int(plan_match.group(1))}: Plan"
|
|
98
|
+
report_match = re.search(r"sessions/[^/]+/(\d+)/report.md$", clean_path)
|
|
99
|
+
if report_match:
|
|
100
|
+
return f"Turn {int(report_match.group(1))}: Report"
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def is_session_file_path(path: str) -> bool:
|
|
105
|
+
"""
|
|
106
|
+
Determines if a path is inside .teddy/sessions/.
|
|
107
|
+
"""
|
|
108
|
+
return "sessions/" in path.lstrip("./")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def is_session_history_path(path: str) -> bool:
|
|
112
|
+
"""
|
|
113
|
+
Determines if a path is a session history file.
|
|
114
|
+
"""
|
|
115
|
+
return get_session_history_display_name(path) is not None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_session_history_sort_key(path: str) -> tuple[int, int]:
|
|
119
|
+
"""
|
|
120
|
+
Sort key for chronological session history: (turn_number, sub_order).
|
|
121
|
+
"""
|
|
122
|
+
clean_path = path.lstrip("./")
|
|
123
|
+
if "initial_request.md" in clean_path:
|
|
124
|
+
return (0, 0)
|
|
125
|
+
plan_match = re.search(r"sessions/[^/]+/(\d+)/plan.md$", clean_path)
|
|
126
|
+
if plan_match:
|
|
127
|
+
return (int(plan_match.group(1)), 1)
|
|
128
|
+
report_match = re.search(r"sessions/[^/]+/(\d+)/report.md$", clean_path)
|
|
129
|
+
if report_match:
|
|
130
|
+
return (int(report_match.group(1)), 2)
|
|
131
|
+
return (999999, 999999)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from typing import Any, Dict
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
import dataclasses
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def scrub_dict_for_serialization(data: Dict[str, Any]) -> Dict[str, Any]:
|
|
10
|
+
"""
|
|
11
|
+
Recursively ensures that MagicMocks and complex objects are neutralized
|
|
12
|
+
for serialization while preserving the structure required by templates.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def scrub(v: Any) -> Any:
|
|
16
|
+
"""Neutralizes MagicMocks for serialization (PLR0911)."""
|
|
17
|
+
# 1. Neutralize Mocks, Primitives, Enums
|
|
18
|
+
if (
|
|
19
|
+
hasattr(v, "_mock_return_value")
|
|
20
|
+
or hasattr(v, "assert_called")
|
|
21
|
+
or "Mock" in str(type(v))
|
|
22
|
+
):
|
|
23
|
+
return "mock_object"
|
|
24
|
+
if isinstance(v, (str, int, float, bool, type(None), datetime, Enum)):
|
|
25
|
+
return v
|
|
26
|
+
|
|
27
|
+
# 2. Handle Dataclasses
|
|
28
|
+
if dataclasses.is_dataclass(v):
|
|
29
|
+
return {f.name: scrub(getattr(v, f.name)) for f in dataclasses.fields(v)}
|
|
30
|
+
|
|
31
|
+
# 3. Recursively handle collections
|
|
32
|
+
if isinstance(v, dict):
|
|
33
|
+
return {k: scrub(val) for k, val in v.items()}
|
|
34
|
+
if isinstance(v, (list, tuple, set)):
|
|
35
|
+
return [scrub(item) for item in v]
|
|
36
|
+
|
|
37
|
+
return str(v)
|
|
38
|
+
|
|
39
|
+
return {k: scrub(v) for k, v in data.items()}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
# Exhaustive English stopwords to strip from session names for conciseness
|
|
4
|
+
STOPWORDS = {
|
|
5
|
+
"a",
|
|
6
|
+
"about",
|
|
7
|
+
"actually",
|
|
8
|
+
"above",
|
|
9
|
+
"after",
|
|
10
|
+
"again",
|
|
11
|
+
"all",
|
|
12
|
+
"also",
|
|
13
|
+
"am",
|
|
14
|
+
"an",
|
|
15
|
+
"and",
|
|
16
|
+
"any",
|
|
17
|
+
"are",
|
|
18
|
+
"as",
|
|
19
|
+
"at",
|
|
20
|
+
"be",
|
|
21
|
+
"because",
|
|
22
|
+
"been",
|
|
23
|
+
"before",
|
|
24
|
+
"being",
|
|
25
|
+
"below",
|
|
26
|
+
"between",
|
|
27
|
+
"both",
|
|
28
|
+
"but",
|
|
29
|
+
"by",
|
|
30
|
+
"can",
|
|
31
|
+
"could",
|
|
32
|
+
"did",
|
|
33
|
+
"do",
|
|
34
|
+
"does",
|
|
35
|
+
"doing",
|
|
36
|
+
"down",
|
|
37
|
+
"during",
|
|
38
|
+
"each",
|
|
39
|
+
"few",
|
|
40
|
+
"for",
|
|
41
|
+
"from",
|
|
42
|
+
"further",
|
|
43
|
+
"get",
|
|
44
|
+
"had",
|
|
45
|
+
"has",
|
|
46
|
+
"have",
|
|
47
|
+
"having",
|
|
48
|
+
"he",
|
|
49
|
+
"hello",
|
|
50
|
+
"help",
|
|
51
|
+
"hey",
|
|
52
|
+
"hi",
|
|
53
|
+
"her",
|
|
54
|
+
"here",
|
|
55
|
+
"hers",
|
|
56
|
+
"herself",
|
|
57
|
+
"him",
|
|
58
|
+
"himself",
|
|
59
|
+
"his",
|
|
60
|
+
"how",
|
|
61
|
+
"i",
|
|
62
|
+
"if",
|
|
63
|
+
"in",
|
|
64
|
+
"into",
|
|
65
|
+
"is",
|
|
66
|
+
"it",
|
|
67
|
+
"its",
|
|
68
|
+
"itself",
|
|
69
|
+
"just",
|
|
70
|
+
"kindly",
|
|
71
|
+
"like",
|
|
72
|
+
"me",
|
|
73
|
+
"more",
|
|
74
|
+
"most",
|
|
75
|
+
"my",
|
|
76
|
+
"myself",
|
|
77
|
+
"no",
|
|
78
|
+
"nor",
|
|
79
|
+
"now",
|
|
80
|
+
"of",
|
|
81
|
+
"off",
|
|
82
|
+
"on",
|
|
83
|
+
"once",
|
|
84
|
+
"only",
|
|
85
|
+
"or",
|
|
86
|
+
"other",
|
|
87
|
+
"please",
|
|
88
|
+
"our",
|
|
89
|
+
"ours",
|
|
90
|
+
"ourselves",
|
|
91
|
+
"out",
|
|
92
|
+
"over",
|
|
93
|
+
"own",
|
|
94
|
+
"s",
|
|
95
|
+
"same",
|
|
96
|
+
"she",
|
|
97
|
+
"should",
|
|
98
|
+
"so",
|
|
99
|
+
"some",
|
|
100
|
+
"such",
|
|
101
|
+
"t",
|
|
102
|
+
"than",
|
|
103
|
+
"thank",
|
|
104
|
+
"thanks",
|
|
105
|
+
"that",
|
|
106
|
+
"the",
|
|
107
|
+
"their",
|
|
108
|
+
"theirs",
|
|
109
|
+
"them",
|
|
110
|
+
"themselves",
|
|
111
|
+
"then",
|
|
112
|
+
"there",
|
|
113
|
+
"these",
|
|
114
|
+
"they",
|
|
115
|
+
"this",
|
|
116
|
+
"those",
|
|
117
|
+
"through",
|
|
118
|
+
"to",
|
|
119
|
+
"too",
|
|
120
|
+
"under",
|
|
121
|
+
"until",
|
|
122
|
+
"up",
|
|
123
|
+
"very",
|
|
124
|
+
"want",
|
|
125
|
+
"was",
|
|
126
|
+
"we",
|
|
127
|
+
"were",
|
|
128
|
+
"what",
|
|
129
|
+
"when",
|
|
130
|
+
"where",
|
|
131
|
+
"which",
|
|
132
|
+
"while",
|
|
133
|
+
"who",
|
|
134
|
+
"whom",
|
|
135
|
+
"why",
|
|
136
|
+
"will",
|
|
137
|
+
"with",
|
|
138
|
+
"would",
|
|
139
|
+
"you",
|
|
140
|
+
"your",
|
|
141
|
+
"yours",
|
|
142
|
+
"yourself",
|
|
143
|
+
"yourselves",
|
|
144
|
+
"using",
|
|
145
|
+
"basically",
|
|
146
|
+
"simply",
|
|
147
|
+
"really",
|
|
148
|
+
"dont",
|
|
149
|
+
"arent",
|
|
150
|
+
"yet",
|
|
151
|
+
"cant",
|
|
152
|
+
"wont",
|
|
153
|
+
"shouldnt",
|
|
154
|
+
"couldnt",
|
|
155
|
+
"didnt",
|
|
156
|
+
"hasnt",
|
|
157
|
+
"havent",
|
|
158
|
+
"isnt",
|
|
159
|
+
"wasnt",
|
|
160
|
+
"werent",
|
|
161
|
+
"im",
|
|
162
|
+
"youre",
|
|
163
|
+
"hes",
|
|
164
|
+
"shes",
|
|
165
|
+
"theyre",
|
|
166
|
+
"ive",
|
|
167
|
+
"youve",
|
|
168
|
+
"weve",
|
|
169
|
+
"theyve",
|
|
170
|
+
"ill",
|
|
171
|
+
"youll",
|
|
172
|
+
"hell",
|
|
173
|
+
"shell",
|
|
174
|
+
"well",
|
|
175
|
+
"theyll",
|
|
176
|
+
"id",
|
|
177
|
+
"youd",
|
|
178
|
+
"hed",
|
|
179
|
+
"shed",
|
|
180
|
+
"wed",
|
|
181
|
+
"theyd",
|
|
182
|
+
"going",
|
|
183
|
+
"gonna",
|
|
184
|
+
"wanna",
|
|
185
|
+
"gotta",
|
|
186
|
+
"ok",
|
|
187
|
+
"okay",
|
|
188
|
+
"sure",
|
|
189
|
+
"surely",
|
|
190
|
+
"maybe",
|
|
191
|
+
"perhaps",
|
|
192
|
+
"already",
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def slugify(text: str, max_length: int = 40) -> str:
|
|
197
|
+
"""
|
|
198
|
+
Converts a string into a URL-friendly slug.
|
|
199
|
+
|
|
200
|
+
1. Lowercase and strip apostrophes (e.g., don't -> dont).
|
|
201
|
+
2. Split into words and remove aggressive stopwords.
|
|
202
|
+
3. Join words with hyphens until max_length is reached (whole-word truncation).
|
|
203
|
+
"""
|
|
204
|
+
# 1. Lowercase and strip apostrophes
|
|
205
|
+
s = text.lower().replace("'", "")
|
|
206
|
+
|
|
207
|
+
# 2. Split and filter stopwords
|
|
208
|
+
all_words = [w for w in re.split(r"[^a-z0-9]+", s) if w and w not in STOPWORDS]
|
|
209
|
+
|
|
210
|
+
# 3. Build slug word by word to respect max_length
|
|
211
|
+
slug_words: list[str] = []
|
|
212
|
+
current_length = 0
|
|
213
|
+
for word in all_words:
|
|
214
|
+
# Word length + hyphen (if not first word)
|
|
215
|
+
added_length = len(word) + (1 if slug_words else 0)
|
|
216
|
+
if current_length + added_length > max_length:
|
|
217
|
+
break
|
|
218
|
+
slug_words.append(word)
|
|
219
|
+
current_length += added_length
|
|
220
|
+
|
|
221
|
+
return "-".join(slug_words)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def truncate_lines(
|
|
225
|
+
content: str,
|
|
226
|
+
max_lines: int,
|
|
227
|
+
direction: str = "tail",
|
|
228
|
+
hint: str = "",
|
|
229
|
+
action_type: str | None = None,
|
|
230
|
+
) -> str:
|
|
231
|
+
"""
|
|
232
|
+
Truncates a string to a maximum number of lines.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
content: The text to truncate.
|
|
236
|
+
max_lines: Maximum number of lines to preserve.
|
|
237
|
+
direction: "head" (preserve first X) or "tail" (preserve last X).
|
|
238
|
+
hint: An optional message to include when truncation occurs.
|
|
239
|
+
action_type: Optional "execute" or "read" to generate a dynamic hint.
|
|
240
|
+
"""
|
|
241
|
+
if not content or max_lines <= 0:
|
|
242
|
+
return content
|
|
243
|
+
|
|
244
|
+
lines = content.splitlines()
|
|
245
|
+
total_lines = len(lines)
|
|
246
|
+
if total_lines <= max_lines:
|
|
247
|
+
return content
|
|
248
|
+
|
|
249
|
+
# Generate dynamic hint if action_type is provided
|
|
250
|
+
if action_type:
|
|
251
|
+
hint = get_truncation_hint(action_type, max_lines, total_lines)
|
|
252
|
+
|
|
253
|
+
if direction == "tail":
|
|
254
|
+
truncated = "\n".join(lines[-max_lines:])
|
|
255
|
+
return f"{hint}\n{truncated}" if hint else truncated
|
|
256
|
+
elif direction == "head":
|
|
257
|
+
truncated = "\n".join(lines[:max_lines])
|
|
258
|
+
return f"{truncated}\n{hint}" if hint else truncated
|
|
259
|
+
else:
|
|
260
|
+
raise ValueError(f"Invalid truncation direction: {direction}")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def get_truncation_hint(action_type: str, max_lines: int, total_lines: int) -> str:
|
|
264
|
+
"""
|
|
265
|
+
Returns a context-specific hint for truncated output based on the action type.
|
|
266
|
+
"""
|
|
267
|
+
action_type = action_type.lower()
|
|
268
|
+
if action_type == "execute":
|
|
269
|
+
return (
|
|
270
|
+
f"[Output truncated: Showing last {max_lines} of {total_lines} lines. "
|
|
271
|
+
"Use the 'Tail' parameter to increase the output limit.]"
|
|
272
|
+
)
|
|
273
|
+
if action_type == "read":
|
|
274
|
+
return (
|
|
275
|
+
f"[Content truncated: Showing first {max_lines} of {total_lines} lines. "
|
|
276
|
+
"Use the 'Lines' parameter to read specific line ranges (e.g., '2-25').]"
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
return f"[Content truncated: Showing {max_lines} of {total_lines} lines.]"
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def extract_lines_range(content: str, lines_spec: str) -> str:
|
|
283
|
+
"""
|
|
284
|
+
Extracts a range of lines from multi-line content based on a lines specification.
|
|
285
|
+
|
|
286
|
+
Supported formats:
|
|
287
|
+
"10-20" → inclusive range from line 10 to line 20 (1‑indexed)
|
|
288
|
+
"-20" → first 20 lines (equivalent to "1-20")
|
|
289
|
+
"50-" → from line 50 to the end
|
|
290
|
+
"5" → single line (line 5)
|
|
291
|
+
anything else → returns full content unchanged (fallback)
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
content: The full file content as a single string.
|
|
295
|
+
lines_spec: The user‑supplied lines parameter (e.g., "10-20").
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
The selected lines as a single string, or the full content if the spec
|
|
299
|
+
is invalid.
|
|
300
|
+
"""
|
|
301
|
+
if not content:
|
|
302
|
+
return ""
|
|
303
|
+
if not lines_spec or not isinstance(lines_spec, str):
|
|
304
|
+
return content
|
|
305
|
+
|
|
306
|
+
lines = content.splitlines()
|
|
307
|
+
total = len(lines)
|
|
308
|
+
|
|
309
|
+
# Try to parse the specification
|
|
310
|
+
spec = lines_spec.strip()
|
|
311
|
+
try:
|
|
312
|
+
if "-" in spec:
|
|
313
|
+
# Range format: "start-end", "-end", or "start-"
|
|
314
|
+
parts = spec.split("-", 1)
|
|
315
|
+
if parts[0] == "" and parts[1] != "":
|
|
316
|
+
# "-end" → first `end` lines
|
|
317
|
+
end = int(parts[1])
|
|
318
|
+
start = 1
|
|
319
|
+
elif parts[1] == "" and parts[0] != "":
|
|
320
|
+
# "start-" → from `start` to end
|
|
321
|
+
start = int(parts[0])
|
|
322
|
+
end = total
|
|
323
|
+
else:
|
|
324
|
+
# "start-end"
|
|
325
|
+
start = int(parts[0]) if parts[0] else 1
|
|
326
|
+
end = int(parts[1]) if parts[1] else total
|
|
327
|
+
else:
|
|
328
|
+
# Single line number: "5"
|
|
329
|
+
line_num = int(spec)
|
|
330
|
+
start = line_num
|
|
331
|
+
end = line_num
|
|
332
|
+
except (ValueError, TypeError):
|
|
333
|
+
# Malformed spec → fall back to full content
|
|
334
|
+
return content
|
|
335
|
+
|
|
336
|
+
# If start line is beyond the file, return empty
|
|
337
|
+
if start > total:
|
|
338
|
+
return ""
|
|
339
|
+
|
|
340
|
+
# Clamp to valid range (1‑indexed)
|
|
341
|
+
start = max(1, min(start, total))
|
|
342
|
+
end = max(start, min(end, total))
|
|
343
|
+
|
|
344
|
+
extracted = lines[start - 1 : end]
|
|
345
|
+
return "\n".join(extracted)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# double_newlines has been removed. It was a band-aid that became harmful:
|
|
349
|
+
# blind \n → \n\n replacement broke tables, lists, and code blocks.
|
|
350
|
+
# The correct fix is raw text extraction in parse_message_action, which preserves
|
|
351
|
+
# the LLM's original formatting exactly. See action_parser_complex.py.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _search_prompt_in_dir(directory: Path, prompt_name: str) -> Optional[str]:
|
|
6
|
+
"""Searches a directory for a prompt file and returns its content."""
|
|
7
|
+
if not directory.is_dir():
|
|
8
|
+
return None
|
|
9
|
+
found_files = list(directory.glob(f"{prompt_name}.*"))
|
|
10
|
+
if found_files:
|
|
11
|
+
return found_files[0].read_text(encoding="utf-8")
|
|
12
|
+
return None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def find_prompt_content(prompt_name: str) -> Optional[str]:
|
|
16
|
+
"""
|
|
17
|
+
Finds prompt content by searching in `.teddy/prompts/` (user-editable)
|
|
18
|
+
by traversing upwards from the current working directory.
|
|
19
|
+
Returns the content as a string, or None if not found.
|
|
20
|
+
Bundled resources are no longer used as a fallback — only `.teddy/prompts/`.
|
|
21
|
+
"""
|
|
22
|
+
current_path = Path.cwd().resolve()
|
|
23
|
+
for path in [current_path] + list(current_path.parents):
|
|
24
|
+
local_prompt_dir = path / ".teddy" / "prompts"
|
|
25
|
+
if content := _search_prompt_in_dir(local_prompt_dir, prompt_name):
|
|
26
|
+
return content
|
|
27
|
+
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def list_prompt_names() -> list[str]:
|
|
32
|
+
"""
|
|
33
|
+
Lists available prompt names by scanning `.teddy/prompts/` directories
|
|
34
|
+
upward from the current working directory.
|
|
35
|
+
Returns a sorted list of prompt names (stems, without any file extension),
|
|
36
|
+
or an empty list if no prompts directory is found.
|
|
37
|
+
"""
|
|
38
|
+
current_path = Path.cwd().resolve()
|
|
39
|
+
for path in [current_path] + list(current_path.parents):
|
|
40
|
+
prompts_dir = path / ".teddy" / "prompts"
|
|
41
|
+
if prompts_dir.is_dir():
|
|
42
|
+
# List all files in the prompts directory and extract stems
|
|
43
|
+
prompt_files = sorted(prompts_dir.glob("*"))
|
|
44
|
+
return [f.stem for f in prompt_files if f.is_file()]
|
|
45
|
+
return []
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Registry modules for container configuration."""
|