ripperdoc 0.2.8__py3-none-any.whl → 0.2.10__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 (94) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +257 -123
  3. ripperdoc/cli/commands/__init__.py +2 -1
  4. ripperdoc/cli/commands/agents_cmd.py +138 -8
  5. ripperdoc/cli/commands/clear_cmd.py +9 -4
  6. ripperdoc/cli/commands/config_cmd.py +1 -1
  7. ripperdoc/cli/commands/context_cmd.py +3 -2
  8. ripperdoc/cli/commands/doctor_cmd.py +18 -4
  9. ripperdoc/cli/commands/exit_cmd.py +1 -0
  10. ripperdoc/cli/commands/hooks_cmd.py +27 -53
  11. ripperdoc/cli/commands/models_cmd.py +27 -10
  12. ripperdoc/cli/commands/permissions_cmd.py +27 -9
  13. ripperdoc/cli/commands/resume_cmd.py +9 -3
  14. ripperdoc/cli/commands/stats_cmd.py +244 -0
  15. ripperdoc/cli/commands/status_cmd.py +4 -4
  16. ripperdoc/cli/commands/tasks_cmd.py +8 -4
  17. ripperdoc/cli/ui/file_mention_completer.py +2 -1
  18. ripperdoc/cli/ui/interrupt_handler.py +2 -3
  19. ripperdoc/cli/ui/message_display.py +4 -2
  20. ripperdoc/cli/ui/panels.py +1 -0
  21. ripperdoc/cli/ui/provider_options.py +247 -0
  22. ripperdoc/cli/ui/rich_ui.py +403 -81
  23. ripperdoc/cli/ui/spinner.py +54 -18
  24. ripperdoc/cli/ui/thinking_spinner.py +1 -2
  25. ripperdoc/cli/ui/tool_renderers.py +8 -2
  26. ripperdoc/cli/ui/wizard.py +213 -0
  27. ripperdoc/core/agents.py +19 -6
  28. ripperdoc/core/config.py +51 -17
  29. ripperdoc/core/custom_commands.py +7 -6
  30. ripperdoc/core/default_tools.py +101 -12
  31. ripperdoc/core/hooks/config.py +1 -3
  32. ripperdoc/core/hooks/events.py +27 -28
  33. ripperdoc/core/hooks/executor.py +4 -6
  34. ripperdoc/core/hooks/integration.py +12 -21
  35. ripperdoc/core/hooks/llm_callback.py +59 -0
  36. ripperdoc/core/hooks/manager.py +40 -15
  37. ripperdoc/core/permissions.py +118 -12
  38. ripperdoc/core/providers/anthropic.py +109 -36
  39. ripperdoc/core/providers/gemini.py +70 -5
  40. ripperdoc/core/providers/openai.py +89 -24
  41. ripperdoc/core/query.py +273 -68
  42. ripperdoc/core/query_utils.py +2 -0
  43. ripperdoc/core/skills.py +9 -3
  44. ripperdoc/core/system_prompt.py +4 -2
  45. ripperdoc/core/tool.py +17 -8
  46. ripperdoc/sdk/client.py +79 -4
  47. ripperdoc/tools/ask_user_question_tool.py +5 -3
  48. ripperdoc/tools/background_shell.py +307 -135
  49. ripperdoc/tools/bash_output_tool.py +1 -1
  50. ripperdoc/tools/bash_tool.py +63 -24
  51. ripperdoc/tools/dynamic_mcp_tool.py +29 -8
  52. ripperdoc/tools/enter_plan_mode_tool.py +1 -1
  53. ripperdoc/tools/exit_plan_mode_tool.py +1 -1
  54. ripperdoc/tools/file_edit_tool.py +167 -54
  55. ripperdoc/tools/file_read_tool.py +28 -4
  56. ripperdoc/tools/file_write_tool.py +13 -10
  57. ripperdoc/tools/glob_tool.py +3 -2
  58. ripperdoc/tools/grep_tool.py +3 -2
  59. ripperdoc/tools/kill_bash_tool.py +1 -1
  60. ripperdoc/tools/ls_tool.py +1 -1
  61. ripperdoc/tools/lsp_tool.py +615 -0
  62. ripperdoc/tools/mcp_tools.py +13 -10
  63. ripperdoc/tools/multi_edit_tool.py +8 -7
  64. ripperdoc/tools/notebook_edit_tool.py +7 -4
  65. ripperdoc/tools/skill_tool.py +1 -1
  66. ripperdoc/tools/task_tool.py +519 -69
  67. ripperdoc/tools/todo_tool.py +2 -2
  68. ripperdoc/tools/tool_search_tool.py +3 -2
  69. ripperdoc/utils/conversation_compaction.py +9 -5
  70. ripperdoc/utils/file_watch.py +214 -5
  71. ripperdoc/utils/json_utils.py +2 -1
  72. ripperdoc/utils/lsp.py +806 -0
  73. ripperdoc/utils/mcp.py +11 -3
  74. ripperdoc/utils/memory.py +4 -2
  75. ripperdoc/utils/message_compaction.py +21 -7
  76. ripperdoc/utils/message_formatting.py +14 -7
  77. ripperdoc/utils/messages.py +126 -67
  78. ripperdoc/utils/path_ignore.py +35 -8
  79. ripperdoc/utils/permissions/path_validation_utils.py +2 -1
  80. ripperdoc/utils/permissions/shell_command_validation.py +427 -91
  81. ripperdoc/utils/permissions/tool_permission_utils.py +174 -15
  82. ripperdoc/utils/safe_get_cwd.py +2 -1
  83. ripperdoc/utils/session_heatmap.py +244 -0
  84. ripperdoc/utils/session_history.py +13 -6
  85. ripperdoc/utils/session_stats.py +293 -0
  86. ripperdoc/utils/todo.py +2 -1
  87. ripperdoc/utils/token_estimation.py +6 -1
  88. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/METADATA +8 -2
  89. ripperdoc-0.2.10.dist-info/RECORD +129 -0
  90. ripperdoc-0.2.8.dist-info/RECORD +0 -121
  91. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/WHEEL +0 -0
  92. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/entry_points.txt +0 -0
  93. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/licenses/LICENSE +0 -0
  94. {ripperdoc-0.2.8.dist-info → ripperdoc-0.2.10.dist-info}/top_level.txt +0 -0
@@ -309,7 +309,7 @@ class TodoWriteTool(Tool[TodoWriteToolInput, TodoToolOutput]):
309
309
  ),
310
310
  ]
311
311
 
312
- async def prompt(self, _safe_mode: bool = False) -> str:
312
+ async def prompt(self, _yolo_mode: bool = False) -> str:
313
313
  return TODO_WRITE_PROMPT
314
314
 
315
315
  def is_read_only(self) -> bool:
@@ -403,7 +403,7 @@ class TodoReadTool(Tool[TodoReadToolInput, TodoToolOutput]):
403
403
  ),
404
404
  ]
405
405
 
406
- async def prompt(self, _safe_mode: bool = False) -> str:
406
+ async def prompt(self, _yolo_mode: bool = False) -> str:
407
407
  return (
408
408
  "Use TodoRead to fetch the current todo list before making progress or when you need "
409
409
  "to confirm the next action. You can request only the next actionable item or filter "
@@ -106,7 +106,7 @@ class ToolSearchTool(Tool[ToolSearchInput, ToolSearchOutput]):
106
106
  ),
107
107
  ]
108
108
 
109
- async def prompt(self, safe_mode: bool = False) -> str: # noqa: ARG002
109
+ async def prompt(self, yolo_mode: bool = False) -> str: # noqa: ARG002
110
110
  return (
111
111
  "Search for a tool by providing a short description (e.g., 'query database', 'render notebook'). "
112
112
  "Use names to activate tools you've already discovered. "
@@ -193,7 +193,8 @@ class ToolSearchTool(Tool[ToolSearchInput, ToolSearchOutput]):
193
193
  description = ""
194
194
  logger.warning(
195
195
  "[tool_search] Failed to build tool description: %s: %s",
196
- type(exc).__name__, exc,
196
+ type(exc).__name__,
197
+ exc,
197
198
  extra={"tool_name": getattr(tool, "name", None)},
198
199
  )
199
200
  doc_text = " ".join([name, tool.user_facing_name(), description])
@@ -34,6 +34,7 @@ RECENT_MESSAGES_AFTER_COMPACT = 8
34
34
  # Summary Prompt Generation
35
35
  # ─────────────────────────────────────────────────────────────────────────────
36
36
 
37
+
37
38
  def generate_summary_prompt(additional_instructions: Optional[str] = None) -> str:
38
39
  """Generate the system prompt for conversation summarization.
39
40
 
@@ -203,9 +204,11 @@ Please continue the conversation from where we left it off without asking the us
203
204
  # Data Classes
204
205
  # ─────────────────────────────────────────────────────────────────────────────
205
206
 
207
+
206
208
  @dataclass
207
209
  class CompactionResult:
208
210
  """Result of a conversation compaction operation."""
211
+
209
212
  messages: List[ConversationMessage]
210
213
  summary_text: str
211
214
  continuation_prompt: str
@@ -219,6 +222,7 @@ class CompactionResult:
219
222
  @dataclass
220
223
  class CompactionError:
221
224
  """Error during compaction."""
225
+
222
226
  error_type: str # "not_enough_messages", "empty_summary", "exception"
223
227
  message: str
224
228
  exception: Optional[Exception] = None
@@ -329,7 +333,7 @@ async def summarize_conversation(
329
333
  system_prompt=system_prompt,
330
334
  tools=[],
331
335
  max_thinking_tokens=0,
332
- model="main",
336
+ model="quick",
333
337
  )
334
338
 
335
339
  result = extract_assistant_text(assistant_response)
@@ -373,16 +377,15 @@ async def compact_conversation(
373
377
  messages_for_summary = micro.messages
374
378
 
375
379
  # Summarize the conversation
376
-
380
+
377
381
  non_progress_messages = [
378
382
  m for m in messages_for_summary if getattr(m, "type", "") != "progress"
379
383
  ]
380
384
  try:
381
- summary_text = await summarize_conversation(
382
- non_progress_messages, custom_instructions
383
- )
385
+ summary_text = await summarize_conversation(non_progress_messages, custom_instructions)
384
386
  except Exception as exc:
385
387
  import traceback
388
+
386
389
  logger.warning(
387
390
  "[compaction] Error during compaction: %s: %s\n%s",
388
391
  type(exc).__name__,
@@ -443,6 +446,7 @@ class ConversationCompactor:
443
446
  Deprecated: Use compact_conversation() function directly instead.
444
447
  This class is kept for backward compatibility.
445
448
  """
449
+
446
450
  # Keep CompactionResult as a nested class for backward compatibility
447
451
  CompactionResult = CompactionResult
448
452
 
@@ -4,13 +4,20 @@ from __future__ import annotations
4
4
 
5
5
  import difflib
6
6
  import os
7
+ import sys
8
+ import threading
9
+ from collections import OrderedDict
7
10
  from dataclasses import dataclass
8
- from typing import Dict, List, Optional
11
+ from typing import Dict, Iterator, List, Optional, Tuple
9
12
 
10
13
  from ripperdoc.utils.log import get_logger
11
14
 
12
15
  logger = get_logger()
13
16
 
17
+ # Default limits for BoundedFileCache
18
+ DEFAULT_MAX_ENTRIES = int(os.getenv("RIPPERDOC_FILE_CACHE_MAX_ENTRIES", "500"))
19
+ DEFAULT_MAX_MEMORY_MB = float(os.getenv("RIPPERDOC_FILE_CACHE_MAX_MEMORY_MB", "50"))
20
+
14
21
 
15
22
  @dataclass
16
23
  class FileSnapshot:
@@ -21,6 +28,198 @@ class FileSnapshot:
21
28
  offset: int = 0
22
29
  limit: Optional[int] = None
23
30
 
31
+ def memory_size(self) -> int:
32
+ """Estimate memory usage of this snapshot in bytes."""
33
+ # String memory: roughly 1 byte per char for ASCII, more for unicode
34
+ # Plus object overhead (~50 bytes for dataclass)
35
+ return sys.getsizeof(self.content) + 50
36
+
37
+
38
+ class BoundedFileCache:
39
+ """Thread-safe LRU cache for FileSnapshots with memory and entry limits.
40
+
41
+ This cache prevents unbounded memory growth in long sessions by:
42
+ 1. Limiting the maximum number of entries (LRU eviction)
43
+ 2. Limiting total memory usage
44
+ 3. Providing thread-safe access
45
+
46
+ Usage:
47
+ cache = BoundedFileCache(max_entries=500, max_memory_mb=50)
48
+ cache["/path/to/file"] = FileSnapshot(content="...", timestamp=123.0)
49
+ snapshot = cache.get("/path/to/file")
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ max_entries: int = DEFAULT_MAX_ENTRIES,
55
+ max_memory_mb: float = DEFAULT_MAX_MEMORY_MB,
56
+ ) -> None:
57
+ """Initialize the bounded cache.
58
+
59
+ Args:
60
+ max_entries: Maximum number of file snapshots to keep
61
+ max_memory_mb: Maximum total memory usage in megabytes
62
+ """
63
+ self._max_entries = max(1, max_entries)
64
+ self._max_memory_bytes = int(max_memory_mb * 1024 * 1024)
65
+ self._cache: OrderedDict[str, FileSnapshot] = OrderedDict()
66
+ self._current_memory = 0
67
+ self._lock = threading.RLock()
68
+ self._eviction_count = 0
69
+
70
+ @property
71
+ def max_entries(self) -> int:
72
+ """Maximum number of entries allowed."""
73
+ return self._max_entries
74
+
75
+ @property
76
+ def max_memory_bytes(self) -> int:
77
+ """Maximum memory in bytes."""
78
+ return self._max_memory_bytes
79
+
80
+ @property
81
+ def current_memory(self) -> int:
82
+ """Current estimated memory usage in bytes."""
83
+ with self._lock:
84
+ return self._current_memory
85
+
86
+ @property
87
+ def eviction_count(self) -> int:
88
+ """Number of entries evicted due to limits."""
89
+ with self._lock:
90
+ return self._eviction_count
91
+
92
+ def __len__(self) -> int:
93
+ with self._lock:
94
+ return len(self._cache)
95
+
96
+ def __contains__(self, key: str) -> bool:
97
+ with self._lock:
98
+ return key in self._cache
99
+
100
+ def __getitem__(self, key: str) -> FileSnapshot:
101
+ with self._lock:
102
+ if key not in self._cache:
103
+ raise KeyError(key)
104
+ # Move to end (most recently used)
105
+ self._cache.move_to_end(key)
106
+ return self._cache[key]
107
+
108
+ def __setitem__(self, key: str, value: FileSnapshot) -> None:
109
+ with self._lock:
110
+ new_size = value.memory_size()
111
+
112
+ # If key exists, remove old entry first (atomic pop to avoid TOCTOU)
113
+ old_value = self._cache.pop(key, None)
114
+ if old_value is not None:
115
+ self._current_memory = max(0, self._current_memory - old_value.memory_size())
116
+
117
+ # Evict entries if needed (memory limit)
118
+ while self._current_memory + new_size > self._max_memory_bytes and self._cache:
119
+ self._evict_oldest()
120
+
121
+ # Evict entries if needed (entry limit)
122
+ while len(self._cache) >= self._max_entries:
123
+ self._evict_oldest()
124
+
125
+ # Add new entry
126
+ self._cache[key] = value
127
+ self._current_memory += new_size
128
+
129
+ def __delitem__(self, key: str) -> None:
130
+ with self._lock:
131
+ # Use atomic pop to avoid TOCTOU between check and delete
132
+ old_value = self._cache.pop(key, None)
133
+ if old_value is not None:
134
+ self._current_memory = max(0, self._current_memory - old_value.memory_size())
135
+
136
+ def _evict_oldest(self) -> None:
137
+ """Evict the least recently used entry. Must be called with lock held."""
138
+ if self._cache:
139
+ oldest_key, oldest_value = self._cache.popitem(last=False)
140
+ self._current_memory = max(0, self._current_memory - oldest_value.memory_size())
141
+ self._eviction_count += 1
142
+ logger.debug(
143
+ "[file_cache] Evicted entry due to cache limits",
144
+ extra={"evicted_path": oldest_key, "total_evictions": self._eviction_count},
145
+ )
146
+
147
+ def get(self, key: str, default: Optional[FileSnapshot] = None) -> Optional[FileSnapshot]:
148
+ """Get a snapshot, returning default if not found."""
149
+ with self._lock:
150
+ if key not in self._cache:
151
+ return default
152
+ self._cache.move_to_end(key)
153
+ return self._cache[key]
154
+
155
+ def pop(self, key: str, default: Optional[FileSnapshot] = None) -> Optional[FileSnapshot]:
156
+ """Remove and return a snapshot."""
157
+ with self._lock:
158
+ if key not in self._cache:
159
+ return default
160
+ value = self._cache.pop(key)
161
+ self._current_memory = max(0, self._current_memory - value.memory_size())
162
+ return value
163
+
164
+ def setdefault(self, key: str, default: FileSnapshot) -> FileSnapshot:
165
+ """Atomically get or set a snapshot.
166
+
167
+ If key exists, return its value (and mark as recently used).
168
+ If key doesn't exist, set it to default and return default.
169
+ This provides a thread-safe get-or-create operation.
170
+ """
171
+ with self._lock:
172
+ if key in self._cache:
173
+ self._cache.move_to_end(key)
174
+ return self._cache[key]
175
+ # Key doesn't exist - add it
176
+ new_size = default.memory_size()
177
+ # Evict if needed
178
+ while self._current_memory + new_size > self._max_memory_bytes and self._cache:
179
+ self._evict_oldest()
180
+ while len(self._cache) >= self._max_entries:
181
+ self._evict_oldest()
182
+ self._cache[key] = default
183
+ self._current_memory += new_size
184
+ return default
185
+
186
+ def clear(self) -> None:
187
+ """Remove all entries from the cache."""
188
+ with self._lock:
189
+ self._cache.clear()
190
+ self._current_memory = 0
191
+
192
+ def keys(self) -> List[str]:
193
+ """Return list of cached file paths."""
194
+ with self._lock:
195
+ return list(self._cache.keys())
196
+
197
+ def values(self) -> List[FileSnapshot]:
198
+ """Return list of cached snapshots."""
199
+ with self._lock:
200
+ return list(self._cache.values())
201
+
202
+ def items(self) -> List[Tuple[str, FileSnapshot]]:
203
+ """Return list of (path, snapshot) pairs."""
204
+ with self._lock:
205
+ return list(self._cache.items())
206
+
207
+ def __iter__(self) -> Iterator[str]:
208
+ """Iterate over keys (not thread-safe for modifications during iteration)."""
209
+ with self._lock:
210
+ return iter(list(self._cache.keys()))
211
+
212
+ def stats(self) -> Dict[str, int]:
213
+ """Return cache statistics."""
214
+ with self._lock:
215
+ return {
216
+ "entries": len(self._cache),
217
+ "max_entries": self._max_entries,
218
+ "memory_bytes": self._current_memory,
219
+ "max_memory_bytes": self._max_memory_bytes,
220
+ "eviction_count": self._eviction_count,
221
+ }
222
+
24
223
 
25
224
  @dataclass
26
225
  class ChangedFileNotice:
@@ -30,10 +229,14 @@ class ChangedFileNotice:
30
229
  summary: str
31
230
 
32
231
 
232
+ # Type alias for cache - supports both Dict and BoundedFileCache
233
+ FileCacheType = Dict[str, FileSnapshot] | BoundedFileCache
234
+
235
+
33
236
  def record_snapshot(
34
237
  file_path: str,
35
238
  content: str,
36
- cache: Dict[str, FileSnapshot],
239
+ cache: FileCacheType,
37
240
  *,
38
241
  offset: int = 0,
39
242
  limit: Optional[int] = None,
@@ -79,7 +282,7 @@ def _build_diff_summary(old_content: str, new_content: str, file_path: str, max_
79
282
 
80
283
 
81
284
  def detect_changed_files(
82
- cache: Dict[str, FileSnapshot], *, max_diff_lines: int = 80
285
+ cache: FileCacheType, *, max_diff_lines: int = 80
83
286
  ) -> List[ChangedFileNotice]:
84
287
  """Return notices for files whose mtime increased since they were read."""
85
288
  notices: List[ChangedFileNotice] = []
@@ -102,10 +305,16 @@ def detect_changed_files(
102
305
 
103
306
  try:
104
307
  new_content = _read_portion(file_path, snapshot.offset, snapshot.limit)
105
- except (OSError, IOError, UnicodeDecodeError, ValueError) as exc: # pragma: no cover - best-effort telemetry
308
+ except (
309
+ OSError,
310
+ IOError,
311
+ UnicodeDecodeError,
312
+ ValueError,
313
+ ) as exc: # pragma: no cover - best-effort telemetry
106
314
  logger.warning(
107
315
  "[file_watch] Failed reading changed file: %s: %s",
108
- type(exc).__name__, exc,
316
+ type(exc).__name__,
317
+ exc,
109
318
  extra={"file_path": file_path},
110
319
  )
111
320
  notices.append(
@@ -21,7 +21,8 @@ def safe_parse_json(json_text: Optional[str], log_error: bool = True) -> Optiona
21
21
  if log_error:
22
22
  logger.debug(
23
23
  "[json_utils] Failed to parse JSON: %s: %s",
24
- type(exc).__name__, exc,
24
+ type(exc).__name__,
25
+ exc,
25
26
  extra={"length": len(json_text)},
26
27
  )
27
28
  return None