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