ripperdoc 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 (81) hide show
  1. ripperdoc/__init__.py +3 -0
  2. ripperdoc/__main__.py +25 -0
  3. ripperdoc/cli/__init__.py +1 -0
  4. ripperdoc/cli/cli.py +317 -0
  5. ripperdoc/cli/commands/__init__.py +76 -0
  6. ripperdoc/cli/commands/agents_cmd.py +234 -0
  7. ripperdoc/cli/commands/base.py +19 -0
  8. ripperdoc/cli/commands/clear_cmd.py +18 -0
  9. ripperdoc/cli/commands/compact_cmd.py +19 -0
  10. ripperdoc/cli/commands/config_cmd.py +31 -0
  11. ripperdoc/cli/commands/context_cmd.py +114 -0
  12. ripperdoc/cli/commands/cost_cmd.py +77 -0
  13. ripperdoc/cli/commands/exit_cmd.py +19 -0
  14. ripperdoc/cli/commands/help_cmd.py +20 -0
  15. ripperdoc/cli/commands/mcp_cmd.py +65 -0
  16. ripperdoc/cli/commands/models_cmd.py +327 -0
  17. ripperdoc/cli/commands/resume_cmd.py +97 -0
  18. ripperdoc/cli/commands/status_cmd.py +167 -0
  19. ripperdoc/cli/commands/tasks_cmd.py +240 -0
  20. ripperdoc/cli/commands/todos_cmd.py +69 -0
  21. ripperdoc/cli/commands/tools_cmd.py +19 -0
  22. ripperdoc/cli/ui/__init__.py +1 -0
  23. ripperdoc/cli/ui/context_display.py +297 -0
  24. ripperdoc/cli/ui/helpers.py +22 -0
  25. ripperdoc/cli/ui/rich_ui.py +1010 -0
  26. ripperdoc/cli/ui/spinner.py +50 -0
  27. ripperdoc/core/__init__.py +1 -0
  28. ripperdoc/core/agents.py +306 -0
  29. ripperdoc/core/commands.py +33 -0
  30. ripperdoc/core/config.py +382 -0
  31. ripperdoc/core/default_tools.py +57 -0
  32. ripperdoc/core/permissions.py +227 -0
  33. ripperdoc/core/query.py +682 -0
  34. ripperdoc/core/system_prompt.py +418 -0
  35. ripperdoc/core/tool.py +214 -0
  36. ripperdoc/sdk/__init__.py +9 -0
  37. ripperdoc/sdk/client.py +309 -0
  38. ripperdoc/tools/__init__.py +1 -0
  39. ripperdoc/tools/background_shell.py +291 -0
  40. ripperdoc/tools/bash_output_tool.py +98 -0
  41. ripperdoc/tools/bash_tool.py +822 -0
  42. ripperdoc/tools/file_edit_tool.py +281 -0
  43. ripperdoc/tools/file_read_tool.py +168 -0
  44. ripperdoc/tools/file_write_tool.py +141 -0
  45. ripperdoc/tools/glob_tool.py +134 -0
  46. ripperdoc/tools/grep_tool.py +232 -0
  47. ripperdoc/tools/kill_bash_tool.py +136 -0
  48. ripperdoc/tools/ls_tool.py +298 -0
  49. ripperdoc/tools/mcp_tools.py +804 -0
  50. ripperdoc/tools/multi_edit_tool.py +393 -0
  51. ripperdoc/tools/notebook_edit_tool.py +325 -0
  52. ripperdoc/tools/task_tool.py +282 -0
  53. ripperdoc/tools/todo_tool.py +362 -0
  54. ripperdoc/tools/tool_search_tool.py +366 -0
  55. ripperdoc/utils/__init__.py +1 -0
  56. ripperdoc/utils/bash_constants.py +51 -0
  57. ripperdoc/utils/bash_output_utils.py +43 -0
  58. ripperdoc/utils/exit_code_handlers.py +241 -0
  59. ripperdoc/utils/log.py +76 -0
  60. ripperdoc/utils/mcp.py +427 -0
  61. ripperdoc/utils/memory.py +239 -0
  62. ripperdoc/utils/message_compaction.py +640 -0
  63. ripperdoc/utils/messages.py +399 -0
  64. ripperdoc/utils/output_utils.py +233 -0
  65. ripperdoc/utils/path_utils.py +46 -0
  66. ripperdoc/utils/permissions/__init__.py +21 -0
  67. ripperdoc/utils/permissions/path_validation_utils.py +165 -0
  68. ripperdoc/utils/permissions/shell_command_validation.py +74 -0
  69. ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
  70. ripperdoc/utils/safe_get_cwd.py +24 -0
  71. ripperdoc/utils/sandbox_utils.py +38 -0
  72. ripperdoc/utils/session_history.py +223 -0
  73. ripperdoc/utils/session_usage.py +110 -0
  74. ripperdoc/utils/shell_token_utils.py +95 -0
  75. ripperdoc/utils/todo.py +199 -0
  76. ripperdoc-0.1.0.dist-info/METADATA +178 -0
  77. ripperdoc-0.1.0.dist-info/RECORD +81 -0
  78. ripperdoc-0.1.0.dist-info/WHEEL +5 -0
  79. ripperdoc-0.1.0.dist-info/entry_points.txt +3 -0
  80. ripperdoc-0.1.0.dist-info/licenses/LICENSE +53 -0
  81. ripperdoc-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,366 @@
1
+ """Tool search helper for deferred tool loading."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ from collections import Counter, defaultdict
8
+ from difflib import SequenceMatcher
9
+ from typing import Any, AsyncGenerator, Dict, List, Optional
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field
12
+
13
+ from ripperdoc.core.tool import (
14
+ Tool,
15
+ ToolOutput,
16
+ ToolResult,
17
+ ToolUseContext,
18
+ ToolUseExample,
19
+ ValidationResult,
20
+ build_tool_description,
21
+ )
22
+
23
+
24
+ class ToolSearchInput(BaseModel):
25
+ """Input for tool search and activation."""
26
+
27
+ query: Optional[str] = Field(
28
+ default=None,
29
+ description="Search phrase describing the capability or tool name you need.",
30
+ )
31
+ names: Optional[List[str]] = Field(
32
+ default=None,
33
+ description="Explicit tool names to activate. Use after seeing search results.",
34
+ )
35
+ max_results: int = Field(
36
+ default=5,
37
+ ge=1,
38
+ le=25,
39
+ description="Maximum number of matching tools to return.",
40
+ )
41
+ include_active: bool = Field(
42
+ default=False,
43
+ description="Include already-active tools in the search results.",
44
+ )
45
+ auto_activate: bool = Field(
46
+ default=True,
47
+ description="If true, activate the returned matches so they can be called immediately.",
48
+ )
49
+ include_examples: bool = Field(
50
+ default=False,
51
+ description="Include input examples in the returned tool descriptions.",
52
+ )
53
+ model_config = ConfigDict(extra="ignore")
54
+
55
+
56
+ class ToolSearchMatch(BaseModel):
57
+ """Metadata about a matching tool."""
58
+
59
+ name: str
60
+ user_facing_name: Optional[str] = None
61
+ description: Optional[str] = None
62
+ active: bool = False
63
+ deferred: bool = False
64
+
65
+
66
+ class ToolSearchOutput(BaseModel):
67
+ """Search results and activation summary."""
68
+
69
+ matches: List[ToolSearchMatch] = Field(default_factory=list)
70
+ activated: List[str] = Field(default_factory=list)
71
+ missing: List[str] = Field(default_factory=list)
72
+ deferred_remaining: int = 0
73
+
74
+
75
+ class ToolSearchTool(Tool[ToolSearchInput, ToolSearchOutput]):
76
+ """Search across available tools and activate deferred ones on demand."""
77
+
78
+ @property
79
+ def name(self) -> str:
80
+ return "ToolSearch"
81
+
82
+ async def description(self) -> str:
83
+ return (
84
+ "Search available tools by name or description, returning a small set of candidates. "
85
+ "Use this when you suspect a capability exists but is not currently active. "
86
+ "Matching deferred tools are automatically activated so you can call them next."
87
+ )
88
+
89
+ @property
90
+ def input_schema(self) -> type[ToolSearchInput]:
91
+ return ToolSearchInput
92
+
93
+ def input_examples(self) -> List[ToolUseExample]:
94
+ return [
95
+ ToolUseExample(
96
+ description="Search for notebook-related tools and activate top results",
97
+ input={"query": "notebook", "max_results": 3},
98
+ ),
99
+ ToolUseExample(
100
+ description="Activate a known tool by name",
101
+ input={"names": ["mcp__search__query"], "auto_activate": True},
102
+ ),
103
+ ]
104
+
105
+ async def prompt(self, safe_mode: bool = False) -> str: # noqa: ARG002
106
+ return (
107
+ "Search for a tool by providing a short description (e.g., 'query database', 'render notebook'). "
108
+ "Use names to activate tools you've already discovered. "
109
+ "Keep queries concise to retrieve the 3-5 most relevant tools."
110
+ )
111
+
112
+ def is_read_only(self) -> bool:
113
+ return True
114
+
115
+ def is_concurrency_safe(self) -> bool:
116
+ return True
117
+
118
+ def needs_permissions(self, input_data: Optional[ToolSearchInput] = None) -> bool: # noqa: ARG002
119
+ return False
120
+
121
+ async def validate_input(
122
+ self, input_data: ToolSearchInput, context: Optional[ToolUseContext] = None # noqa: ARG002
123
+ ) -> ValidationResult:
124
+ if not (input_data.query or input_data.names):
125
+ return ValidationResult(
126
+ result=False,
127
+ message="Provide a search query or explicit tool names to load.",
128
+ )
129
+ return ValidationResult(result=True)
130
+
131
+ def render_result_for_assistant(self, output: ToolSearchOutput) -> str:
132
+ lines = []
133
+ if output.activated:
134
+ lines.append(f"Activated: {', '.join(sorted(output.activated))}")
135
+ if output.matches:
136
+ lines.append("Matches:")
137
+ for match in output.matches:
138
+ status = []
139
+ if match.active:
140
+ status.append("active")
141
+ if match.deferred:
142
+ status.append("deferred")
143
+ status_text = f" ({', '.join(status)})" if status else ""
144
+ lines.append(f"- {match.name}{status_text}: {match.description or ''}".strip())
145
+ if output.missing:
146
+ lines.append(f"Unknown tool names: {', '.join(sorted(output.missing))}")
147
+ if output.deferred_remaining:
148
+ lines.append(f"Deferred tools remaining: {output.deferred_remaining}")
149
+ return "\n".join(lines) if lines else "No matching tools found."
150
+
151
+ def render_tool_use_message(self, input_data: ToolSearchInput, verbose: bool = False) -> str:
152
+ detail = f'"{input_data.query}"' if input_data.query else ", ".join(input_data.names or [])
153
+ return f"Search tools for {detail}"
154
+
155
+ async def _search(
156
+ self,
157
+ query: str,
158
+ registry: Any,
159
+ *,
160
+ include_active: bool,
161
+ include_examples: bool,
162
+ limit: int,
163
+ ) -> List[Dict[str, Any]]:
164
+ """Regex + BM25-style search over tool metadata."""
165
+ normalized = (query or "").strip().lower()
166
+ if not normalized:
167
+ return []
168
+
169
+ regex: Optional[re.Pattern[str]] = None
170
+ if normalized.startswith("/") and normalized.endswith("/") and len(normalized) > 2:
171
+ try:
172
+ regex = re.compile(normalized[1:-1], re.IGNORECASE)
173
+ except re.error:
174
+ regex = None
175
+
176
+ def _tokenize(text: str) -> List[str]:
177
+ return re.findall(r"[a-z0-9]+", text.lower())
178
+
179
+ corpus: List[tuple[str, Any, List[str], int, str]] = []
180
+ for name, tool in registry.iter_named_tools():
181
+ try:
182
+ description = await build_tool_description(
183
+ tool, include_examples=include_examples, max_examples=2
184
+ )
185
+ except Exception:
186
+ description = ""
187
+ doc_text = " ".join([name, tool.user_facing_name(), description])
188
+ tokens = _tokenize(doc_text)
189
+ corpus.append((name, tool, tokens, len(tokens), description))
190
+
191
+ if not corpus:
192
+ return []
193
+
194
+ avg_len = sum(doc_len for _, _, _, doc_len, _ in corpus) / len(corpus)
195
+ query_terms = _tokenize(normalized)
196
+ df = defaultdict(int)
197
+ for _, _, tokens, _, _ in corpus:
198
+ seen_terms = set(tokens)
199
+ for term in query_terms:
200
+ if term in seen_terms:
201
+ df[term] += 1
202
+
203
+ k1 = 1.5
204
+ b = 0.75
205
+
206
+ def _bm25_score(tokens: List[str], doc_len: int) -> float:
207
+ score = 0.0
208
+ counts = Counter(tokens)
209
+ for term in query_terms:
210
+ if term not in counts:
211
+ continue
212
+ tf = counts[term]
213
+ df_term = df.get(term, 0) or 1
214
+ idf = math.log((len(corpus) - df_term + 0.5) / (df_term + 0.5) + 1)
215
+ numerator = tf * (k1 + 1)
216
+ denom = tf + k1 * (1 - b + b * (doc_len / (avg_len or 1)))
217
+ score += idf * (numerator / denom)
218
+ return score
219
+
220
+ results: List[Dict[str, Any]] = []
221
+ for name, tool, tokens, doc_len, description in corpus:
222
+ if not include_active and registry.is_active(name):
223
+ continue
224
+
225
+ combined_text = " ".join([name, tool.user_facing_name(), description]).lower()
226
+ score = _bm25_score(tokens, doc_len)
227
+ if regex and regex.search(combined_text):
228
+ score += 5.0
229
+ if normalized in combined_text:
230
+ score += 3.0
231
+ score += SequenceMatcher(None, normalized, name.lower()).ratio() * 2
232
+ score += SequenceMatcher(None, normalized, tool.user_facing_name().lower()).ratio()
233
+
234
+ results.append(
235
+ {
236
+ "name": name,
237
+ "user_facing_name": tool.user_facing_name(),
238
+ "active": registry.is_active(name),
239
+ "deferred": name in getattr(registry, "deferred_names", set()),
240
+ "description": description,
241
+ "input_schema": tool.input_schema.model_json_schema(),
242
+ "score": score,
243
+ }
244
+ )
245
+
246
+ return sorted(results, key=lambda item: item.get("score", 0), reverse=True)[:limit]
247
+
248
+ async def _describe_by_name(
249
+ self,
250
+ registry: Any,
251
+ names: List[str],
252
+ include_examples: bool,
253
+ limit: int,
254
+ ) -> List[Dict[str, Any]]:
255
+ seen = set()
256
+ results: List[Dict[str, Any]] = []
257
+ for name in names:
258
+ if not name or name in seen:
259
+ continue
260
+ seen.add(name)
261
+ tool = registry.get(name) if hasattr(registry, "get") else None
262
+ if not tool:
263
+ continue
264
+ description = await build_tool_description(
265
+ tool, include_examples=include_examples, max_examples=2
266
+ )
267
+ results.append(
268
+ {
269
+ "name": name,
270
+ "user_facing_name": tool.user_facing_name(),
271
+ "description": description,
272
+ "active": getattr(registry, "is_active", lambda *_: False)(name)
273
+ if hasattr(registry, "is_active")
274
+ else False,
275
+ "deferred": name in getattr(registry, "deferred_names", set()),
276
+ "score": 0.0,
277
+ }
278
+ )
279
+ if len(results) >= limit:
280
+ break
281
+ return results
282
+
283
+ async def call(
284
+ self,
285
+ input_data: ToolSearchInput,
286
+ context: ToolUseContext,
287
+ ) -> AsyncGenerator[ToolOutput, None]:
288
+ registry = getattr(context, "tool_registry", None)
289
+ if not registry:
290
+ yield ToolResult(
291
+ data=ToolSearchOutput(),
292
+ result_for_assistant="Tool registry unavailable; cannot search tools.",
293
+ )
294
+ return
295
+
296
+ matches: List[Dict[str, Any]] = []
297
+ if input_data.query:
298
+ matches = await self._search(
299
+ input_data.query,
300
+ registry,
301
+ include_active=input_data.include_active,
302
+ include_examples=input_data.include_examples,
303
+ limit=input_data.max_results,
304
+ )
305
+
306
+ if input_data.names:
307
+ named_matches = await self._describe_by_name(
308
+ registry,
309
+ input_data.names,
310
+ input_data.include_examples,
311
+ input_data.max_results,
312
+ )
313
+ # Merge in explicit names that weren't returned by the search query.
314
+ known = {m["name"] for m in matches}
315
+ matches.extend([m for m in named_matches if m["name"] not in known])
316
+
317
+ if matches:
318
+ matches = sorted(matches, key=lambda item: item.get("score", 0), reverse=True)
319
+ if input_data.max_results:
320
+ matches = matches[: input_data.max_results]
321
+
322
+ max_description_chars = 600
323
+ for match in matches:
324
+ desc = match.get("description")
325
+ if (
326
+ max_description_chars
327
+ and isinstance(desc, str)
328
+ and len(desc) > max_description_chars
329
+ ):
330
+ match["description"] = desc[:max_description_chars] + "..."
331
+
332
+ # Activate tools as requested.
333
+ activation_targets: List[str] = []
334
+ if input_data.names:
335
+ activation_targets.extend(input_data.names)
336
+ elif input_data.auto_activate:
337
+ activation_targets.extend([match["name"] for match in matches])
338
+
339
+ activated: List[str] = []
340
+ missing: List[str] = []
341
+ if activation_targets:
342
+ activated, missing = registry.activate_tools(activation_targets)
343
+
344
+ normalized_matches: List[ToolSearchMatch] = []
345
+ for match in matches[: input_data.max_results]:
346
+ normalized_matches.append(
347
+ ToolSearchMatch(
348
+ name=match.get("name", ""),
349
+ user_facing_name=match.get("user_facing_name"),
350
+ description=match.get("description"),
351
+ active=bool(match.get("active")),
352
+ deferred=bool(match.get("deferred")),
353
+ )
354
+ )
355
+
356
+ output = ToolSearchOutput(
357
+ matches=normalized_matches,
358
+ activated=activated,
359
+ missing=missing,
360
+ deferred_remaining=len(getattr(registry, "deferred_names", [])),
361
+ )
362
+
363
+ yield ToolResult(
364
+ data=output,
365
+ result_for_assistant=self.render_result_for_assistant(output),
366
+ )
@@ -0,0 +1 @@
1
+ """Utility modules for Ripperdoc."""
@@ -0,0 +1,51 @@
1
+ """Bash-related constants and helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Optional
7
+
8
+ # Baseline defaults (kept in sync with the reference implementation)
9
+ _BASH_DEFAULT_TIMEOUT_MS = 120_000
10
+ _BASH_MAX_TIMEOUT_MS = 600_000
11
+ _BASH_MAX_OUTPUT_LENGTH = 30_000
12
+
13
+
14
+ def _parse_positive_int(value: Optional[str]) -> Optional[int]:
15
+ """Best-effort conversion of an environment variable to a positive int."""
16
+ if value is None:
17
+ return None
18
+ try:
19
+ parsed = int(value)
20
+ except (TypeError, ValueError):
21
+ return None
22
+ return parsed if parsed > 0 else None
23
+
24
+
25
+ def get_bash_max_output_length() -> int:
26
+ """Return the maximum output length, honoring an env override when valid."""
27
+ override = _parse_positive_int(os.getenv("BASH_MAX_OUTPUT_LENGTH"))
28
+ return override or _BASH_MAX_OUTPUT_LENGTH
29
+
30
+
31
+ def get_bash_default_timeout_ms() -> int:
32
+ """Return the default timeout, honoring an env override when valid."""
33
+ override = _parse_positive_int(os.getenv("BASH_DEFAULT_TIMEOUT_MS"))
34
+ return override or _BASH_DEFAULT_TIMEOUT_MS
35
+
36
+
37
+ def get_bash_max_timeout_ms() -> int:
38
+ """Return the maximum timeout, never lower than the default timeout."""
39
+ override = _parse_positive_int(os.getenv("BASH_MAX_TIMEOUT_MS"))
40
+ baseline = _parse_positive_int(os.getenv("BASH_DEFAULT_TIMEOUT_MS"))
41
+ default_timeout = baseline or _BASH_DEFAULT_TIMEOUT_MS
42
+ if override:
43
+ return max(override, default_timeout)
44
+ return max(_BASH_MAX_TIMEOUT_MS, default_timeout)
45
+
46
+
47
+ __all__ = [
48
+ "get_bash_max_output_length",
49
+ "get_bash_default_timeout_ms",
50
+ "get_bash_max_timeout_ms",
51
+ ]
@@ -0,0 +1,43 @@
1
+ """Output helpers for BashTool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from ripperdoc.utils.output_utils import trim_blank_lines, truncate_output
8
+ from ripperdoc.utils.safe_get_cwd import get_original_cwd, safe_get_cwd
9
+
10
+
11
+ def append_cwd_reset_message(message: str) -> str:
12
+ """Append a notice when the working directory gets reset."""
13
+ cleaned = message.rstrip()
14
+ suffix = f"Shell cwd was reset to {get_original_cwd()}"
15
+ if cleaned:
16
+ return f"{cleaned}\n{suffix}"
17
+ return suffix
18
+
19
+
20
+ def reset_cwd_if_needed(allowed_directories: set[str] | None = None) -> bool:
21
+ """Placeholder that mirrors the reference contract.
22
+
23
+ In this environment we simply report whether the current cwd is outside the
24
+ provided allowed set and reset to the original cwd if so.
25
+ """
26
+ allowed_directories = allowed_directories or set()
27
+ current = safe_get_cwd()
28
+ if not allowed_directories:
29
+ return False
30
+ if current in allowed_directories:
31
+ return False
32
+ os.chdir(get_original_cwd())
33
+ return True
34
+
35
+
36
+ __all__ = [
37
+ "append_cwd_reset_message",
38
+ "reset_cwd_if_needed",
39
+ "trim_blank_lines",
40
+ "truncate_output",
41
+ "safe_get_cwd",
42
+ "get_original_cwd",
43
+ ]