soothe-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 (107) hide show
  1. soothe_cli/__init__.py +5 -0
  2. soothe_cli/cli/__init__.py +1 -0
  3. soothe_cli/cli/commands/__init__.py +1 -0
  4. soothe_cli/cli/commands/autopilot_cmd.py +410 -0
  5. soothe_cli/cli/commands/config_cmd.py +277 -0
  6. soothe_cli/cli/commands/run_cmd.py +87 -0
  7. soothe_cli/cli/commands/status_cmd.py +121 -0
  8. soothe_cli/cli/commands/subagent_names.py +17 -0
  9. soothe_cli/cli/commands/thread_cmd.py +657 -0
  10. soothe_cli/cli/execution/__init__.py +6 -0
  11. soothe_cli/cli/execution/daemon.py +194 -0
  12. soothe_cli/cli/execution/headless.py +99 -0
  13. soothe_cli/cli/execution/launcher.py +31 -0
  14. soothe_cli/cli/main.py +509 -0
  15. soothe_cli/cli/renderer.py +444 -0
  16. soothe_cli/cli/stream/__init__.py +17 -0
  17. soothe_cli/cli/stream/context.py +138 -0
  18. soothe_cli/cli/stream/display_line.py +83 -0
  19. soothe_cli/cli/stream/formatter.py +412 -0
  20. soothe_cli/cli/stream/pipeline.py +521 -0
  21. soothe_cli/cli/utils.py +46 -0
  22. soothe_cli/config/__init__.py +5 -0
  23. soothe_cli/config/cli_config.py +155 -0
  24. soothe_cli/plan/__init__.py +5 -0
  25. soothe_cli/plan/rich_tree.py +54 -0
  26. soothe_cli/shared/__init__.py +107 -0
  27. soothe_cli/shared/command_router.py +246 -0
  28. soothe_cli/shared/config_loader.py +68 -0
  29. soothe_cli/shared/display_policy.py +413 -0
  30. soothe_cli/shared/essential_events.py +68 -0
  31. soothe_cli/shared/event_processor.py +823 -0
  32. soothe_cli/shared/message_processing.py +393 -0
  33. soothe_cli/shared/presentation_engine.py +173 -0
  34. soothe_cli/shared/processor_state.py +80 -0
  35. soothe_cli/shared/renderer_protocol.py +158 -0
  36. soothe_cli/shared/rendering.py +43 -0
  37. soothe_cli/shared/slash_commands.py +354 -0
  38. soothe_cli/shared/subagent_routing.py +63 -0
  39. soothe_cli/shared/suppression_state.py +188 -0
  40. soothe_cli/shared/tool_formatters/__init__.py +27 -0
  41. soothe_cli/shared/tool_formatters/base.py +109 -0
  42. soothe_cli/shared/tool_formatters/execution.py +297 -0
  43. soothe_cli/shared/tool_formatters/fallback.py +128 -0
  44. soothe_cli/shared/tool_formatters/file_ops.py +299 -0
  45. soothe_cli/shared/tool_formatters/goal_formatter.py +331 -0
  46. soothe_cli/shared/tool_formatters/media.py +291 -0
  47. soothe_cli/shared/tool_formatters/structured.py +202 -0
  48. soothe_cli/shared/tool_formatters/web.py +143 -0
  49. soothe_cli/shared/tool_output_formatter.py +227 -0
  50. soothe_cli/shared/tui_trace_log.py +40 -0
  51. soothe_cli/tui/__init__.py +5 -0
  52. soothe_cli/tui/_ask_user_types.py +50 -0
  53. soothe_cli/tui/_cli_context.py +27 -0
  54. soothe_cli/tui/_env_vars.py +56 -0
  55. soothe_cli/tui/_session_stats.py +114 -0
  56. soothe_cli/tui/_version.py +21 -0
  57. soothe_cli/tui/app.py +4992 -0
  58. soothe_cli/tui/app.tcss +302 -0
  59. soothe_cli/tui/command_registry.py +310 -0
  60. soothe_cli/tui/config.py +2381 -0
  61. soothe_cli/tui/daemon_session.py +233 -0
  62. soothe_cli/tui/file_ops.py +409 -0
  63. soothe_cli/tui/formatting.py +28 -0
  64. soothe_cli/tui/hooks.py +23 -0
  65. soothe_cli/tui/input.py +782 -0
  66. soothe_cli/tui/media_utils.py +471 -0
  67. soothe_cli/tui/model_config.py +518 -0
  68. soothe_cli/tui/output.py +69 -0
  69. soothe_cli/tui/project_utils.py +188 -0
  70. soothe_cli/tui/sessions.py +1248 -0
  71. soothe_cli/tui/skills/__init__.py +5 -0
  72. soothe_cli/tui/skills/invocation.py +74 -0
  73. soothe_cli/tui/skills/load.py +93 -0
  74. soothe_cli/tui/textual_adapter.py +1430 -0
  75. soothe_cli/tui/theme.py +838 -0
  76. soothe_cli/tui/tool_display.py +297 -0
  77. soothe_cli/tui/unicode_security.py +502 -0
  78. soothe_cli/tui/update_check.py +447 -0
  79. soothe_cli/tui/widgets/__init__.py +9 -0
  80. soothe_cli/tui/widgets/_links.py +63 -0
  81. soothe_cli/tui/widgets/approval.py +430 -0
  82. soothe_cli/tui/widgets/ask_user.py +392 -0
  83. soothe_cli/tui/widgets/autocomplete.py +666 -0
  84. soothe_cli/tui/widgets/autopilot_dashboard.py +308 -0
  85. soothe_cli/tui/widgets/autopilot_screen.py +64 -0
  86. soothe_cli/tui/widgets/chat_input.py +1834 -0
  87. soothe_cli/tui/widgets/clipboard.py +128 -0
  88. soothe_cli/tui/widgets/diff.py +240 -0
  89. soothe_cli/tui/widgets/editor.py +140 -0
  90. soothe_cli/tui/widgets/history.py +221 -0
  91. soothe_cli/tui/widgets/loading.py +194 -0
  92. soothe_cli/tui/widgets/mcp_viewer.py +352 -0
  93. soothe_cli/tui/widgets/message_store.py +693 -0
  94. soothe_cli/tui/widgets/messages.py +1720 -0
  95. soothe_cli/tui/widgets/model_selector.py +988 -0
  96. soothe_cli/tui/widgets/notification_settings.py +155 -0
  97. soothe_cli/tui/widgets/status.py +403 -0
  98. soothe_cli/tui/widgets/theme_selector.py +158 -0
  99. soothe_cli/tui/widgets/thread_selector.py +1865 -0
  100. soothe_cli/tui/widgets/tool_renderers.py +148 -0
  101. soothe_cli/tui/widgets/tool_widgets.py +254 -0
  102. soothe_cli/tui/widgets/tools.py +165 -0
  103. soothe_cli/tui/widgets/welcome.py +330 -0
  104. soothe_cli-0.1.0.dist-info/METADATA +100 -0
  105. soothe_cli-0.1.0.dist-info/RECORD +107 -0
  106. soothe_cli-0.1.0.dist-info/WHEEL +4 -0
  107. soothe_cli-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,148 @@
1
+ """Tool renderers for approval widgets - registry pattern."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import difflib
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from soothe_cli.tui.widgets.tool_widgets import (
9
+ EditFileApprovalWidget,
10
+ GenericApprovalWidget,
11
+ WriteFileApprovalWidget,
12
+ )
13
+
14
+ if TYPE_CHECKING:
15
+ from soothe_cli.tui.widgets.tool_widgets import ToolApprovalWidget
16
+
17
+
18
+ class ToolRenderer:
19
+ """Strategy for building a tool's HITL approval widget.
20
+
21
+ Each renderer maps a tool name to a `(widget_class, data)` pair that
22
+ controls what the user sees in the approval box. Tools not registered
23
+ in `_RENDERER_REGISTRY` fall through to the default, which dumps all
24
+ args as `key: value` lines via `GenericApprovalWidget`.
25
+ """
26
+
27
+ @staticmethod
28
+ def get_approval_widget(
29
+ tool_args: dict[str, Any],
30
+ ) -> tuple[type[ToolApprovalWidget], dict[str, Any]]:
31
+ """Get the approval widget class and data for this tool.
32
+
33
+ Args:
34
+ tool_args: The tool arguments from action_request
35
+
36
+ Returns:
37
+ Tuple of (widget_class, data_dict)
38
+ """
39
+ return GenericApprovalWidget, tool_args
40
+
41
+
42
+ class WriteFileRenderer(ToolRenderer):
43
+ """Renderer for write_file tool - shows full file content."""
44
+
45
+ @staticmethod
46
+ def get_approval_widget( # noqa: D102 # Protocol method — docstring on base class
47
+ tool_args: dict[str, Any],
48
+ ) -> tuple[type[ToolApprovalWidget], dict[str, Any]]:
49
+ # Extract file extension for syntax highlighting
50
+ file_path = tool_args.get("file_path", "")
51
+ content = tool_args.get("content", "")
52
+
53
+ # Get file extension
54
+ file_extension = "text"
55
+ if "." in file_path:
56
+ file_extension = file_path.rsplit(".", 1)[-1]
57
+
58
+ data = {
59
+ "file_path": file_path,
60
+ "content": content,
61
+ "file_extension": file_extension,
62
+ }
63
+ return WriteFileApprovalWidget, data
64
+
65
+
66
+ class TaskRenderer(ToolRenderer):
67
+ """Renderer for task tool — interrupt description provides full context."""
68
+
69
+ @staticmethod
70
+ def get_approval_widget( # noqa: D102 # Protocol method — docstring on base class
71
+ tool_args: dict[str, Any], # noqa: ARG004 # Unused; interrupt description already formats task args
72
+ ) -> tuple[type[ToolApprovalWidget], dict[str, Any]]:
73
+ return GenericApprovalWidget, {}
74
+
75
+
76
+ class EditFileRenderer(ToolRenderer):
77
+ """Renderer for edit_file tool - shows unified diff."""
78
+
79
+ @staticmethod
80
+ def get_approval_widget( # noqa: D102 # Protocol method — docstring on base class
81
+ tool_args: dict[str, Any],
82
+ ) -> tuple[type[ToolApprovalWidget], dict[str, Any]]:
83
+ file_path = tool_args.get("file_path", "")
84
+ old_string = tool_args.get("old_string", "")
85
+ new_string = tool_args.get("new_string", "")
86
+
87
+ # Generate unified diff
88
+ diff_lines = EditFileRenderer._generate_diff(old_string, new_string)
89
+
90
+ data = {
91
+ "file_path": file_path,
92
+ "diff_lines": diff_lines,
93
+ "old_string": old_string,
94
+ "new_string": new_string,
95
+ }
96
+ return EditFileApprovalWidget, data
97
+
98
+ @staticmethod
99
+ def _generate_diff(old_string: str, new_string: str) -> list[str]:
100
+ """Generate unified diff lines from old and new strings.
101
+
102
+ Returns:
103
+ List of diff lines without the file headers.
104
+ """
105
+ if not old_string and not new_string:
106
+ return []
107
+
108
+ old_lines = old_string.split("\n") if old_string else []
109
+ new_lines = new_string.split("\n") if new_string else []
110
+
111
+ # Generate unified diff
112
+ diff = difflib.unified_diff(
113
+ old_lines,
114
+ new_lines,
115
+ fromfile="before",
116
+ tofile="after",
117
+ lineterm="",
118
+ n=3, # Context lines
119
+ )
120
+
121
+ # Skip the first two header lines (--- and +++)
122
+ diff_list = list(diff)
123
+ return diff_list[2:] if len(diff_list) > 2 else diff_list # noqa: PLR2004 # Column count threshold
124
+
125
+
126
+ _RENDERER_REGISTRY: dict[str, type[ToolRenderer]] = {
127
+ "task": TaskRenderer,
128
+ "write_file": WriteFileRenderer,
129
+ "edit_file": EditFileRenderer,
130
+ }
131
+ """Registry mapping tool names to renderers
132
+
133
+ Note: bash/shell/execute use minimal approval (no renderer) — see
134
+ ApprovalMenu._MINIMAL_TOOLS
135
+ """
136
+
137
+
138
+ def get_renderer(tool_name: str) -> ToolRenderer:
139
+ """Get the renderer for a tool by name.
140
+
141
+ Args:
142
+ tool_name: The name of the tool
143
+
144
+ Returns:
145
+ The appropriate ToolRenderer instance
146
+ """
147
+ renderer_class = _RENDERER_REGISTRY.get(tool_name, ToolRenderer)
148
+ return renderer_class()
@@ -0,0 +1,254 @@
1
+ """Tool-specific approval widgets for HITL display."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from textual.containers import Vertical
8
+ from textual.content import Content
9
+ from textual.widgets import Markdown, Static
10
+
11
+ from soothe_cli.tui import theme
12
+
13
+ if TYPE_CHECKING:
14
+ from textual.app import ComposeResult
15
+
16
+ # Constants for display limits
17
+ _MAX_VALUE_LEN = 200
18
+ _MAX_LINES = 30
19
+ _MAX_DIFF_LINES = 50
20
+ _MAX_PREVIEW_LINES = 20
21
+
22
+
23
+ def _format_stats(additions: int, deletions: int) -> Content:
24
+ """Format addition/deletion stats as styled Content.
25
+
26
+ Args:
27
+ additions: Number of added lines.
28
+ deletions: Number of removed lines.
29
+
30
+ Returns:
31
+ Styled Content showing additions and deletions.
32
+ """
33
+ colors = theme.get_theme_colors()
34
+ parts: list[str | tuple[str, str] | Content] = []
35
+ if additions:
36
+ parts.append((f"+{additions}", colors.success))
37
+ if deletions:
38
+ if parts:
39
+ parts.append(" ")
40
+ parts.append((f"-{deletions}", colors.error))
41
+ return Content.assemble(*parts) if parts else Content("")
42
+
43
+
44
+ def _file_header(file_path: str, additions: int = 0, deletions: int = 0) -> ComposeResult:
45
+ """Yield the `File:` path header with optional `+N -M` stats.
46
+
47
+ Args:
48
+ file_path: Path to the file being modified.
49
+ additions: Number of added lines.
50
+ deletions: Number of removed lines.
51
+
52
+ Yields:
53
+ Static widgets for the file path header and a spacer line.
54
+ """
55
+ stats = _format_stats(additions, deletions)
56
+ yield Static(
57
+ Content.assemble(
58
+ Content.from_markup("[bold cyan]File:[/bold cyan] $path ", path=file_path),
59
+ stats,
60
+ )
61
+ )
62
+ yield Static("")
63
+
64
+
65
+ def _count_diff_stats(diff_lines: list[str], old_string: str, new_string: str) -> tuple[int, int]:
66
+ """Count additions and deletions from diff data.
67
+
68
+ Args:
69
+ diff_lines: Unified diff output lines.
70
+ old_string: Original text being replaced (fallback when no diff).
71
+ new_string: Replacement text (fallback when no diff).
72
+
73
+ Returns:
74
+ Tuple of (additions count, deletions count).
75
+ """
76
+ if diff_lines:
77
+ additions = sum(
78
+ 1 for line in diff_lines if line.startswith("+") and not line.startswith("+++")
79
+ )
80
+ deletions = sum(
81
+ 1 for line in diff_lines if line.startswith("-") and not line.startswith("---")
82
+ )
83
+ else:
84
+ additions = new_string.count("\n") + 1 if new_string else 0
85
+ deletions = old_string.count("\n") + 1 if old_string else 0
86
+ return additions, deletions
87
+
88
+
89
+ class ToolApprovalWidget(Vertical):
90
+ """Base class for tool approval widgets."""
91
+
92
+ def __init__(self, data: dict[str, Any]) -> None:
93
+ """Initialize the tool approval widget with data."""
94
+ super().__init__(classes="tool-approval-widget")
95
+ self.data = data
96
+
97
+ def compose(self) -> ComposeResult: # noqa: PLR6301 # Textual widget method convention
98
+ """Default compose - override in subclasses.
99
+
100
+ Yields:
101
+ Static widget with placeholder message.
102
+ """
103
+ yield Static("Tool details not available", classes="approval-description")
104
+
105
+
106
+ class GenericApprovalWidget(ToolApprovalWidget):
107
+ """Generic approval widget for unknown tools."""
108
+
109
+ def compose(self) -> ComposeResult:
110
+ """Compose the generic tool display.
111
+
112
+ Yields:
113
+ Static widgets displaying each key-value pair from tool data.
114
+ """
115
+ for key, value in self.data.items():
116
+ if value is None:
117
+ continue
118
+ value_str = str(value)
119
+ if len(value_str) > _MAX_VALUE_LEN:
120
+ hidden = len(value_str) - _MAX_VALUE_LEN
121
+ value_str = value_str[:_MAX_VALUE_LEN] + f"... ({hidden} more chars)"
122
+ yield Static(f"{key}: {value_str}", markup=False, classes="approval-description")
123
+
124
+
125
+ class WriteFileApprovalWidget(ToolApprovalWidget):
126
+ """Approval widget for write_file - shows file content with syntax highlighting."""
127
+
128
+ def compose(self) -> ComposeResult:
129
+ """Compose the file content display with syntax highlighting.
130
+
131
+ Yields:
132
+ Widgets displaying file path header and syntax-highlighted content.
133
+ """
134
+ file_path = self.data.get("file_path", "")
135
+ content = self.data.get("content", "")
136
+ file_extension = self.data.get("file_extension", "text")
137
+
138
+ # Content with syntax highlighting via Markdown code block
139
+ lines = content.split("\n")
140
+ total_lines = len(lines)
141
+
142
+ # File header with line count
143
+ yield from _file_header(file_path, additions=total_lines if content else 0)
144
+
145
+ if total_lines > _MAX_LINES:
146
+ # Truncate for display
147
+ shown_lines = lines[:_MAX_LINES]
148
+ remaining = total_lines - _MAX_LINES
149
+ truncated_content = "\n".join(shown_lines) + f"\n... ({remaining} more lines)"
150
+ yield Markdown(f"```{file_extension}\n{truncated_content}\n```")
151
+ else:
152
+ yield Markdown(f"```{file_extension}\n{content}\n```")
153
+
154
+
155
+ class EditFileApprovalWidget(ToolApprovalWidget):
156
+ """Approval widget for edit_file - shows clean diff with colors."""
157
+
158
+ def compose(self) -> ComposeResult:
159
+ """Compose the diff display with colored additions and deletions.
160
+
161
+ Yields:
162
+ Widgets displaying file path, stats, and colored diff lines.
163
+ """
164
+ file_path = self.data.get("file_path", "")
165
+ diff_lines = self.data.get("diff_lines", [])
166
+ old_string = self.data.get("old_string", "")
167
+ new_string = self.data.get("new_string", "")
168
+
169
+ additions, deletions = _count_diff_stats(diff_lines, old_string, new_string)
170
+ yield from _file_header(file_path, additions, deletions)
171
+
172
+ if not diff_lines and not old_string and not new_string:
173
+ yield Static("No changes to display", classes="approval-description")
174
+ elif diff_lines:
175
+ # Render content
176
+ yield from self._render_diff_lines_only(diff_lines)
177
+ else:
178
+ yield from self._render_strings_only(old_string, new_string)
179
+
180
+ def _render_diff_lines_only(self, diff_lines: list[str]) -> ComposeResult:
181
+ """Render unified diff lines without returning stats.
182
+
183
+ Yields:
184
+ Static widgets for each diff line with appropriate styling.
185
+ """
186
+ lines_shown = 0
187
+
188
+ for line in diff_lines:
189
+ if lines_shown >= _MAX_DIFF_LINES:
190
+ yield Static(
191
+ Content.styled(f"... ({len(diff_lines) - lines_shown} more lines)", "dim")
192
+ )
193
+ break
194
+
195
+ if line.startswith(("@@", "---", "+++")):
196
+ continue
197
+
198
+ widget = self._render_diff_line(line)
199
+ if widget:
200
+ yield widget
201
+ lines_shown += 1
202
+
203
+ def _render_strings_only(self, old_string: str, new_string: str) -> ComposeResult:
204
+ """Render old/new strings without returning stats.
205
+
206
+ Yields:
207
+ Static widgets showing removed and added content with styling.
208
+ """
209
+ colors = theme.get_theme_colors()
210
+ if old_string:
211
+ yield Static(Content.styled("Removing:", f"bold {colors.error}"))
212
+ yield from self._render_string_lines(old_string, is_addition=False)
213
+ yield Static("")
214
+
215
+ if new_string:
216
+ yield Static(Content.styled("Adding:", f"bold {colors.success}"))
217
+ yield from self._render_string_lines(new_string, is_addition=True)
218
+
219
+ @staticmethod
220
+ def _render_diff_line(line: str) -> Static | None:
221
+ """Render a single diff line with appropriate styling.
222
+
223
+ Returns:
224
+ Static widget with styled diff line, or None for empty/skipped lines.
225
+ """
226
+ raw = line[1:] if len(line) > 1 else ""
227
+
228
+ if line.startswith("-"):
229
+ return Static(Content.from_markup("- $text", text=raw), classes="diff-removed")
230
+ if line.startswith("+"):
231
+ return Static(Content.from_markup("+ $text", text=raw), classes="diff-added")
232
+ if line.startswith(" "):
233
+ return Static(Content.from_markup(" $text", text=raw), classes="diff-context")
234
+ if line.strip():
235
+ return Static(line, markup=False)
236
+ return None
237
+
238
+ @staticmethod
239
+ def _render_string_lines(text: str, *, is_addition: bool) -> ComposeResult:
240
+ """Render lines from a string with appropriate styling.
241
+
242
+ Yields:
243
+ Static widgets for each line with addition or deletion styling.
244
+ """
245
+ lines = text.split("\n")
246
+ sign = "+" if is_addition else "-"
247
+ cls = "diff-added" if is_addition else "diff-removed"
248
+
249
+ for line in lines[:_MAX_PREVIEW_LINES]:
250
+ yield Static(Content.from_markup(f"{sign} $text", text=line), classes=cls)
251
+
252
+ if len(lines) > _MAX_PREVIEW_LINES:
253
+ remaining = len(lines) - _MAX_PREVIEW_LINES
254
+ yield Static(Content.styled(f"... ({remaining} more lines)", "dim"))
@@ -0,0 +1,165 @@
1
+ """Custom tools for the CLI agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any, Literal
6
+
7
+ if TYPE_CHECKING:
8
+ from tavily import TavilyClient
9
+
10
+ _UNSET = object()
11
+ _tavily_client: TavilyClient | object | None = _UNSET
12
+
13
+
14
+ def _get_tavily_client() -> TavilyClient | None:
15
+ """Get or initialize the lazy Tavily client singleton.
16
+
17
+ Returns:
18
+ TavilyClient instance, or None if API key is not configured.
19
+ """
20
+ global _tavily_client # noqa: PLW0603 # Module-level cache requires global statement
21
+ if _tavily_client is not _UNSET:
22
+ return _tavily_client # type: ignore[return-value] # narrowed by sentinel check
23
+
24
+ from soothe_cli.tui.config import settings
25
+
26
+ if settings.has_tavily:
27
+ from tavily import TavilyClient as _TavilyClient
28
+
29
+ _tavily_client = _TavilyClient(api_key=settings.tavily_api_key)
30
+ else:
31
+ _tavily_client = None
32
+ return _tavily_client
33
+
34
+
35
+ def web_search( # noqa: ANN201 # Return type depends on dynamic tool configuration
36
+ query: str,
37
+ max_results: int = 5,
38
+ topic: Literal["general", "news", "finance"] = "general",
39
+ include_raw_content: bool = False,
40
+ ):
41
+ """Search the web using Tavily for current information and documentation.
42
+
43
+ This tool searches the web and returns relevant results. After receiving results,
44
+ you MUST synthesize the information into a natural, helpful response for the user.
45
+
46
+ Args:
47
+ query: The search query (be specific and detailed)
48
+ max_results: Number of results to return (default: 5)
49
+ topic: Search topic type - "general" for most queries, "news" for current events
50
+ include_raw_content: Include full page content (warning: uses more tokens)
51
+
52
+ Returns:
53
+ Dictionary containing:
54
+ - results: List of search results, each with:
55
+ - title: Page title
56
+ - url: Page URL
57
+ - content: Relevant excerpt from the page
58
+ - score: Relevance score (0-1)
59
+ - query: The original search query
60
+
61
+ IMPORTANT: After using this tool:
62
+ 1. Read through the 'content' field of each result
63
+ 2. Extract relevant information that answers the user's question
64
+ 3. Synthesize this into a clear, natural language response
65
+ 4. Cite sources by mentioning the page titles or URLs
66
+ 5. NEVER show the raw JSON to the user - always provide a formatted response
67
+ """
68
+ try:
69
+ import requests
70
+ from tavily import (
71
+ BadRequestError,
72
+ InvalidAPIKeyError,
73
+ MissingAPIKeyError,
74
+ UsageLimitExceededError,
75
+ )
76
+ from tavily.errors import ForbiddenError
77
+ from tavily.errors import TimeoutError as TavilyTimeoutError
78
+ except ImportError as exc:
79
+ return {
80
+ "error": f"Required package not installed: {exc.name}. Install with: pip install 'Soothe[cli]'",
81
+ "query": query,
82
+ }
83
+
84
+ client = _get_tavily_client()
85
+ if client is None:
86
+ return {
87
+ "error": "Tavily API key not configured. Please set TAVILY_API_KEY environment variable.",
88
+ "query": query,
89
+ }
90
+
91
+ try:
92
+ return client.search(
93
+ query,
94
+ max_results=max_results,
95
+ include_raw_content=include_raw_content,
96
+ topic=topic,
97
+ )
98
+ except (
99
+ requests.exceptions.RequestException,
100
+ ValueError,
101
+ TypeError,
102
+ # Tavily-specific exceptions
103
+ BadRequestError,
104
+ ForbiddenError,
105
+ InvalidAPIKeyError,
106
+ MissingAPIKeyError,
107
+ TavilyTimeoutError,
108
+ UsageLimitExceededError,
109
+ ) as e:
110
+ return {"error": f"Web search error: {e!s}", "query": query}
111
+
112
+
113
+ def fetch_url(url: str, timeout: int = 30) -> dict[str, Any]:
114
+ """Fetch content from a URL and convert HTML to markdown format.
115
+
116
+ This tool fetches web page content and converts it to clean markdown text,
117
+ making it easy to read and process HTML content. After receiving the markdown,
118
+ you MUST synthesize the information into a natural, helpful response for the user.
119
+
120
+ Args:
121
+ url: The URL to fetch (must be a valid HTTP/HTTPS URL)
122
+ timeout: Request timeout in seconds (default: 30)
123
+
124
+ Returns:
125
+ Dictionary containing:
126
+ - success: Whether the request succeeded
127
+ - url: The final URL after redirects
128
+ - markdown_content: The page content converted to markdown
129
+ - status_code: HTTP status code
130
+ - content_length: Length of the markdown content in characters
131
+
132
+ IMPORTANT: After using this tool:
133
+ 1. Read through the markdown content
134
+ 2. Extract relevant information that answers the user's question
135
+ 3. Synthesize this into a clear, natural language response
136
+ 4. NEVER show the raw markdown to the user unless specifically requested
137
+ """
138
+ try:
139
+ import requests
140
+ from markdownify import markdownify
141
+ except ImportError as exc:
142
+ return {
143
+ "error": f"Required package not installed: {exc.name}. Install with: pip install 'Soothe[cli]'",
144
+ "url": url,
145
+ }
146
+
147
+ try:
148
+ response = requests.get(
149
+ url,
150
+ timeout=timeout,
151
+ headers={"User-Agent": "Mozilla/5.0 (compatible; Soothe/1.0)"},
152
+ )
153
+ response.raise_for_status()
154
+
155
+ # Convert HTML content to markdown
156
+ markdown_content = markdownify(response.text)
157
+
158
+ return {
159
+ "url": str(response.url),
160
+ "markdown_content": markdown_content,
161
+ "status_code": response.status_code,
162
+ "content_length": len(markdown_content),
163
+ }
164
+ except requests.exceptions.RequestException as e:
165
+ return {"error": f"Fetch URL error: {e!s}", "url": url}