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
ripperdoc/core/skills.py CHANGED
@@ -84,10 +84,15 @@ def _split_frontmatter(raw_text: str) -> Tuple[Dict[str, Any], str]:
84
84
  body = "\n".join(lines[idx + 1 :])
85
85
  try:
86
86
  frontmatter = yaml.safe_load(frontmatter_text) or {}
87
- except (yaml.YAMLError, ValueError, TypeError) as exc: # pragma: no cover - defensive
87
+ except (
88
+ yaml.YAMLError,
89
+ ValueError,
90
+ TypeError,
91
+ ) as exc: # pragma: no cover - defensive
88
92
  logger.warning(
89
93
  "[skills] Invalid frontmatter in SKILL.md: %s: %s",
90
- type(exc).__name__, exc,
94
+ type(exc).__name__,
95
+ exc,
91
96
  )
92
97
  return {"__error__": f"Invalid frontmatter: {exc}"}, body
93
98
  return frontmatter, body
@@ -118,7 +123,8 @@ def _load_skill_file(
118
123
  except (OSError, IOError, UnicodeDecodeError) as exc:
119
124
  logger.warning(
120
125
  "[skills] Failed to read skill file: %s: %s",
121
- type(exc).__name__, exc,
126
+ type(exc).__name__,
127
+ exc,
122
128
  extra={"path": str(path)},
123
129
  )
124
130
  return None, SkillLoadError(path=path, reason=f"Failed to read file: {exc}")
@@ -49,7 +49,8 @@ def _detect_git_repo(cwd: Path) -> bool:
49
49
  except (OSError, subprocess.SubprocessError) as exc:
50
50
  logger.warning(
51
51
  "[system_prompt] Failed to detect git repository: %s: %s",
52
- type(exc).__name__, exc,
52
+ type(exc).__name__,
53
+ exc,
53
54
  extra={"cwd": str(cwd)},
54
55
  )
55
56
  return False
@@ -393,7 +394,8 @@ def build_system_prompt(
393
394
  except (OSError, ValueError, RuntimeError) as exc:
394
395
  logger.warning(
395
396
  "Failed to load agent definitions: %s: %s",
396
- type(exc).__name__, exc,
397
+ type(exc).__name__,
398
+ exc,
397
399
  )
398
400
  agent_section = (
399
401
  "# Subagents\nTask tool available, but agent definitions could not be loaded."
ripperdoc/core/tool.py CHANGED
@@ -8,7 +8,7 @@ import json
8
8
  from abc import ABC, abstractmethod
9
9
  from typing import Annotated, Any, AsyncGenerator, Dict, List, Optional, TypeVar, Generic, Union
10
10
  from pydantic import BaseModel, ConfigDict, Field, SkipValidation
11
- from ripperdoc.utils.file_watch import FileSnapshot
11
+ from ripperdoc.utils.file_watch import FileCacheType
12
12
  from ripperdoc.utils.log import get_logger
13
13
 
14
14
 
@@ -37,13 +37,20 @@ class ToolUseContext(BaseModel):
37
37
 
38
38
  message_id: Optional[str] = None
39
39
  agent_id: Optional[str] = None
40
- safe_mode: bool = False
40
+ yolo_mode: bool = False
41
41
  verbose: bool = False
42
42
  permission_checker: Optional[Any] = None
43
43
  read_file_timestamps: Dict[str, float] = Field(default_factory=dict)
44
- # SkipValidation prevents Pydantic from copying the dict during validation,
45
- # ensuring Read and Edit tools share the same cache instance
46
- file_state_cache: Annotated[Dict[str, FileSnapshot], SkipValidation] = Field(default_factory=dict)
44
+ # SkipValidation prevents Pydantic from copying the cache during validation,
45
+ # ensuring Read and Edit tools share the same cache instance.
46
+ # FileCacheType supports both Dict[str, FileSnapshot] and BoundedFileCache.
47
+ file_state_cache: Annotated[FileCacheType, SkipValidation] = Field(
48
+ default_factory=dict
49
+ )
50
+ conversation_messages: Annotated[Optional[List[Any]], SkipValidation] = Field(
51
+ default=None,
52
+ description="Full conversation history for tools that need parent context.",
53
+ )
47
54
  tool_registry: Optional[Any] = None
48
55
  abort_signal: Optional[Any] = None
49
56
  # UI control callbacks for tools that need user interaction
@@ -110,7 +117,7 @@ class Tool(ABC, Generic[TInput, TOutput]):
110
117
  pass
111
118
 
112
119
  @abstractmethod
113
- async def prompt(self, safe_mode: bool = False) -> str:
120
+ async def prompt(self, yolo_mode: bool = False) -> str:
114
121
  """Get the system prompt for this tool."""
115
122
  pass
116
123
 
@@ -213,7 +220,8 @@ async def build_tool_description(
213
220
  except (TypeError, ValueError, AttributeError, KeyError) as exc:
214
221
  logger.warning(
215
222
  "[tool] Failed to build input example section: %s: %s",
216
- type(exc).__name__, exc,
223
+ type(exc).__name__,
224
+ exc,
217
225
  extra={"tool": getattr(tool, "name", None)},
218
226
  )
219
227
  return description_text
@@ -233,7 +241,8 @@ def tool_input_examples(tool: Tool[Any, Any], limit: int = 5) -> List[Dict[str,
233
241
  except (TypeError, ValueError, AttributeError) as exc:
234
242
  logger.warning(
235
243
  "[tool] Failed to format tool input example: %s: %s",
236
- type(exc).__name__, exc,
244
+ type(exc).__name__,
245
+ exc,
237
246
  extra={"tool": getattr(tool, "name", None)},
238
247
  )
239
248
  continue
ripperdoc/sdk/client.py CHANGED
@@ -8,6 +8,8 @@ from __future__ import annotations
8
8
 
9
9
  import asyncio
10
10
  import os
11
+ import time
12
+ import uuid
11
13
  from dataclasses import dataclass, field
12
14
  from pathlib import Path
13
15
  from typing import (
@@ -24,6 +26,8 @@ from typing import (
24
26
  )
25
27
 
26
28
  from ripperdoc.core.default_tools import get_default_tools
29
+ from ripperdoc.core.hooks.llm_callback import build_hook_llm_callback
30
+ from ripperdoc.core.hooks.manager import hook_manager
27
31
  from ripperdoc.core.query import QueryContext, query as _core_query
28
32
  from ripperdoc.core.permissions import PermissionResult
29
33
  from ripperdoc.core.system_prompt import build_system_prompt
@@ -36,6 +40,7 @@ from ripperdoc.utils.messages import (
36
40
  AssistantMessage,
37
41
  ProgressMessage,
38
42
  UserMessage,
43
+ create_assistant_message,
39
44
  create_user_message,
40
45
  )
41
46
  from ripperdoc.utils.mcp import (
@@ -83,7 +88,7 @@ class RipperdocOptions:
83
88
  tools: Optional[Sequence[Tool[Any, Any]]] = None
84
89
  allowed_tools: Optional[Sequence[str]] = None
85
90
  disallowed_tools: Optional[Sequence[str]] = None
86
- safe_mode: bool = False
91
+ yolo_mode: bool = False
87
92
  verbose: bool = False
88
93
  model: str = "main"
89
94
  max_thinking_tokens: int = 0
@@ -159,6 +164,10 @@ class RipperdocClient:
159
164
  self._current_context: Optional[QueryContext] = None
160
165
  self._connected = False
161
166
  self._previous_cwd: Optional[Path] = None
167
+ self._session_hook_contexts: List[str] = []
168
+ self._session_id: Optional[str] = None
169
+ self._session_start_time: Optional[float] = None
170
+ self._session_end_sent: bool = False
162
171
 
163
172
  @property
164
173
  def tools(self) -> List[Tool[Any, Any]]:
@@ -182,6 +191,22 @@ class RipperdocClient:
182
191
  self._previous_cwd = Path.cwd()
183
192
  os.chdir(_coerce_to_path(self.options.cwd))
184
193
  self._connected = True
194
+ project_path = _coerce_to_path(self.options.cwd or Path.cwd())
195
+ hook_manager.set_project_dir(project_path)
196
+ self._session_id = self._session_id or str(uuid.uuid4())
197
+ hook_manager.set_session_id(self._session_id)
198
+ hook_manager.set_llm_callback(build_hook_llm_callback())
199
+ try:
200
+ result = await hook_manager.run_session_start_async("startup")
201
+ self._session_hook_contexts = self._collect_hook_contexts(result)
202
+ self._session_start_time = time.time()
203
+ self._session_end_sent = False
204
+ except (OSError, RuntimeError, ConnectionError, ValueError, TypeError) as exc:
205
+ logger.warning(
206
+ "[sdk] SessionStart hook failed: %s: %s",
207
+ type(exc).__name__,
208
+ exc,
209
+ )
185
210
 
186
211
  if prompt:
187
212
  await self.query(prompt)
@@ -203,6 +228,25 @@ class RipperdocClient:
203
228
  self._previous_cwd = None
204
229
 
205
230
  self._connected = False
231
+ if not self._session_end_sent:
232
+ duration = (
233
+ max(time.time() - self._session_start_time, 0.0)
234
+ if self._session_start_time is not None
235
+ else None
236
+ )
237
+ try:
238
+ await hook_manager.run_session_end_async(
239
+ "other",
240
+ duration_seconds=duration,
241
+ message_count=len(self._history),
242
+ )
243
+ except (OSError, RuntimeError, ConnectionError, ValueError, TypeError) as exc:
244
+ logger.warning(
245
+ "[sdk] SessionEnd hook failed: %s: %s",
246
+ type(exc).__name__,
247
+ exc,
248
+ )
249
+ self._session_end_sent = True
206
250
  await shutdown_mcp_runtime()
207
251
 
208
252
  async def query(self, prompt: str) -> None:
@@ -217,17 +261,32 @@ class RipperdocClient:
217
261
 
218
262
  self._queue = asyncio.Queue()
219
263
 
264
+ hook_result = await hook_manager.run_user_prompt_submit_async(prompt)
265
+ if hook_result.should_block or not hook_result.should_continue:
266
+ reason = (
267
+ hook_result.block_reason
268
+ or hook_result.stop_reason
269
+ or "Prompt blocked by hook."
270
+ )
271
+ blocked_message = create_assistant_message(str(reason))
272
+ self._history.append(blocked_message)
273
+ await self._queue.put(blocked_message)
274
+ await self._queue.put(_END_OF_STREAM)
275
+ self._current_task = asyncio.create_task(asyncio.sleep(0))
276
+ return
277
+ hook_instructions = self._collect_hook_contexts(hook_result)
278
+
220
279
  user_message = create_user_message(prompt)
221
280
  history = list(self._history) + [user_message]
222
281
  self._history.append(user_message)
223
282
 
224
- system_prompt = await self._build_system_prompt(prompt)
283
+ system_prompt = await self._build_system_prompt(prompt, hook_instructions)
225
284
  context = dict(self.options.context)
226
285
 
227
286
  query_context = QueryContext(
228
287
  tools=self._tools,
229
288
  max_thinking_tokens=self.options.max_thinking_tokens,
230
- safe_mode=self.options.safe_mode,
289
+ yolo_mode=self.options.yolo_mode,
231
290
  model=self.options.model,
232
291
  verbose=self.options.verbose,
233
292
  )
@@ -280,7 +339,9 @@ class RipperdocClient:
280
339
 
281
340
  await self._queue.put(_END_OF_STREAM)
282
341
 
283
- async def _build_system_prompt(self, user_prompt: str) -> str:
342
+ async def _build_system_prompt(
343
+ self, user_prompt: str, hook_instructions: Optional[List[str]] = None
344
+ ) -> str:
284
345
  if self.options.system_prompt:
285
346
  return self.options.system_prompt
286
347
 
@@ -299,6 +360,10 @@ class RipperdocClient:
299
360
  memory = build_memory_instructions()
300
361
  if memory:
301
362
  instructions.append(memory)
363
+ if self._session_hook_contexts:
364
+ instructions.extend(self._session_hook_contexts)
365
+ if hook_instructions:
366
+ instructions.extend([text for text in hook_instructions if text])
302
367
 
303
368
  dynamic_tools = await load_dynamic_mcp_tools_async(project_path)
304
369
  if dynamic_tools:
@@ -315,6 +380,16 @@ class RipperdocClient:
315
380
  mcp_instructions=mcp_instructions,
316
381
  )
317
382
 
383
+ def _collect_hook_contexts(self, hook_result: Any) -> List[str]:
384
+ contexts: List[str] = []
385
+ system_message = getattr(hook_result, "system_message", None)
386
+ additional_context = getattr(hook_result, "additional_context", None)
387
+ if system_message:
388
+ contexts.append(str(system_message))
389
+ if additional_context:
390
+ contexts.append(str(additional_context))
391
+ return contexts
392
+
318
393
 
319
394
  async def query(
320
395
  prompt: str,
@@ -228,7 +228,8 @@ async def prompt_user_for_answer(
228
228
  except (OSError, RuntimeError, ValueError) as e:
229
229
  logger.warning(
230
230
  "[ask_user_question_tool] Error during prompt: %s: %s",
231
- type(e).__name__, e,
231
+ type(e).__name__,
232
+ e,
232
233
  )
233
234
  return None
234
235
 
@@ -275,7 +276,7 @@ class AskUserQuestionTool(Tool[AskUserQuestionToolInput, AskUserQuestionToolOutp
275
276
  def input_schema(self) -> type[AskUserQuestionToolInput]:
276
277
  return AskUserQuestionToolInput
277
278
 
278
- async def prompt(self, safe_mode: bool = False) -> str: # noqa: ARG002
279
+ async def prompt(self, yolo_mode: bool = False) -> str: # noqa: ARG002
279
280
  return ASK_USER_QUESTION_PROMPT
280
281
 
281
282
  def user_facing_name(self) -> str:
@@ -410,7 +411,8 @@ class AskUserQuestionTool(Tool[AskUserQuestionToolInput, AskUserQuestionToolOutp
410
411
  except (OSError, RuntimeError, ValueError, KeyError) as exc:
411
412
  logger.warning(
412
413
  "[ask_user_question_tool] Error collecting answers: %s: %s",
413
- type(exc).__name__, exc,
414
+ type(exc).__name__,
415
+ exc,
414
416
  )
415
417
  output = AskUserQuestionToolOutput(
416
418
  questions=questions,