ripperdoc 0.2.7__py3-none-any.whl → 0.2.9__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 (87) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +33 -115
  3. ripperdoc/cli/commands/__init__.py +70 -6
  4. ripperdoc/cli/commands/agents_cmd.py +6 -3
  5. ripperdoc/cli/commands/clear_cmd.py +1 -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/help_cmd.py +11 -1
  10. ripperdoc/cli/commands/hooks_cmd.py +610 -0
  11. ripperdoc/cli/commands/models_cmd.py +26 -9
  12. ripperdoc/cli/commands/permissions_cmd.py +57 -37
  13. ripperdoc/cli/commands/resume_cmd.py +6 -4
  14. ripperdoc/cli/commands/status_cmd.py +4 -4
  15. ripperdoc/cli/commands/tasks_cmd.py +8 -4
  16. ripperdoc/cli/ui/file_mention_completer.py +64 -8
  17. ripperdoc/cli/ui/interrupt_handler.py +3 -4
  18. ripperdoc/cli/ui/message_display.py +5 -3
  19. ripperdoc/cli/ui/panels.py +13 -10
  20. ripperdoc/cli/ui/provider_options.py +247 -0
  21. ripperdoc/cli/ui/rich_ui.py +196 -77
  22. ripperdoc/cli/ui/spinner.py +25 -1
  23. ripperdoc/cli/ui/tool_renderers.py +8 -2
  24. ripperdoc/cli/ui/wizard.py +215 -0
  25. ripperdoc/core/agents.py +9 -3
  26. ripperdoc/core/config.py +49 -12
  27. ripperdoc/core/custom_commands.py +412 -0
  28. ripperdoc/core/default_tools.py +11 -2
  29. ripperdoc/core/hooks/__init__.py +99 -0
  30. ripperdoc/core/hooks/config.py +301 -0
  31. ripperdoc/core/hooks/events.py +535 -0
  32. ripperdoc/core/hooks/executor.py +496 -0
  33. ripperdoc/core/hooks/integration.py +344 -0
  34. ripperdoc/core/hooks/manager.py +745 -0
  35. ripperdoc/core/permissions.py +40 -8
  36. ripperdoc/core/providers/anthropic.py +548 -68
  37. ripperdoc/core/providers/gemini.py +70 -5
  38. ripperdoc/core/providers/openai.py +60 -5
  39. ripperdoc/core/query.py +140 -39
  40. ripperdoc/core/query_utils.py +2 -0
  41. ripperdoc/core/skills.py +9 -3
  42. ripperdoc/core/system_prompt.py +4 -2
  43. ripperdoc/core/tool.py +9 -5
  44. ripperdoc/sdk/client.py +2 -2
  45. ripperdoc/tools/ask_user_question_tool.py +5 -3
  46. ripperdoc/tools/background_shell.py +2 -1
  47. ripperdoc/tools/bash_output_tool.py +1 -1
  48. ripperdoc/tools/bash_tool.py +30 -20
  49. ripperdoc/tools/dynamic_mcp_tool.py +29 -8
  50. ripperdoc/tools/enter_plan_mode_tool.py +1 -1
  51. ripperdoc/tools/exit_plan_mode_tool.py +1 -1
  52. ripperdoc/tools/file_edit_tool.py +8 -4
  53. ripperdoc/tools/file_read_tool.py +9 -5
  54. ripperdoc/tools/file_write_tool.py +9 -5
  55. ripperdoc/tools/glob_tool.py +3 -2
  56. ripperdoc/tools/grep_tool.py +3 -2
  57. ripperdoc/tools/kill_bash_tool.py +1 -1
  58. ripperdoc/tools/ls_tool.py +1 -1
  59. ripperdoc/tools/mcp_tools.py +13 -10
  60. ripperdoc/tools/multi_edit_tool.py +8 -7
  61. ripperdoc/tools/notebook_edit_tool.py +7 -4
  62. ripperdoc/tools/skill_tool.py +1 -1
  63. ripperdoc/tools/task_tool.py +5 -4
  64. ripperdoc/tools/todo_tool.py +2 -2
  65. ripperdoc/tools/tool_search_tool.py +3 -2
  66. ripperdoc/utils/conversation_compaction.py +11 -7
  67. ripperdoc/utils/file_watch.py +8 -2
  68. ripperdoc/utils/json_utils.py +2 -1
  69. ripperdoc/utils/mcp.py +11 -3
  70. ripperdoc/utils/memory.py +4 -2
  71. ripperdoc/utils/message_compaction.py +21 -7
  72. ripperdoc/utils/message_formatting.py +11 -7
  73. ripperdoc/utils/messages.py +105 -66
  74. ripperdoc/utils/path_ignore.py +38 -12
  75. ripperdoc/utils/permissions/path_validation_utils.py +2 -1
  76. ripperdoc/utils/permissions/shell_command_validation.py +427 -91
  77. ripperdoc/utils/safe_get_cwd.py +2 -1
  78. ripperdoc/utils/session_history.py +13 -6
  79. ripperdoc/utils/todo.py +2 -1
  80. ripperdoc/utils/token_estimation.py +6 -1
  81. {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/METADATA +24 -3
  82. ripperdoc-0.2.9.dist-info/RECORD +123 -0
  83. ripperdoc-0.2.7.dist-info/RECORD +0 -113
  84. {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/WHEEL +0 -0
  85. {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/entry_points.txt +0 -0
  86. {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/licenses/LICENSE +0 -0
  87. {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/top_level.txt +0 -0
@@ -4,7 +4,6 @@ This module provides utilities for creating and normalizing messages
4
4
  for communication with AI models.
5
5
  """
6
6
 
7
- import json
8
7
  from typing import Any, Dict, List, Optional, Union
9
8
  from pydantic import BaseModel, ConfigDict, Field
10
9
  from uuid import uuid4
@@ -93,7 +92,8 @@ def _content_block_to_openai(block: MessageContent) -> Dict[str, Any]:
93
92
  except (TypeError, ValueError) as exc:
94
93
  logger.warning(
95
94
  "[_content_block_to_openai] Failed to serialize tool arguments: %s: %s",
96
- type(exc).__name__, exc,
95
+ type(exc).__name__,
96
+ exc,
97
97
  )
98
98
  args_str = "{}"
99
99
  tool_call_id = (
@@ -211,7 +211,8 @@ def create_user_message(
211
211
  # Fallback: keep as-is if conversion fails
212
212
  logger.warning(
213
213
  "[create_user_message] Failed to normalize tool_use_result: %s: %s",
214
- type(exc).__name__, exc,
214
+ type(exc).__name__,
215
+ exc,
215
216
  )
216
217
 
217
218
  message = Message(role=MessageRole.USER, content=message_content)
@@ -268,14 +269,80 @@ def create_progress_message(
268
269
  )
269
270
 
270
271
 
272
+ def _apply_deepseek_reasoning_content(
273
+ normalized: List[Dict[str, Any]],
274
+ is_new_turn: bool = False,
275
+ ) -> List[Dict[str, Any]]:
276
+ """Apply DeepSeek reasoning_content handling to normalized messages.
277
+
278
+ DeepSeek thinking mode requires special handling for tool calls:
279
+ 1. During a tool call loop (same turn), reasoning_content MUST be preserved
280
+ in assistant messages that contain tool_calls
281
+ 2. When a new user turn starts, we can optionally clear previous reasoning_content
282
+ to save bandwidth (the API will ignore them anyway)
283
+
284
+ According to DeepSeek docs, an assistant message with tool_calls should look like:
285
+ {
286
+ 'role': 'assistant',
287
+ 'content': response.choices[0].message.content,
288
+ 'reasoning_content': response.choices[0].message.reasoning_content,
289
+ 'tool_calls': response.choices[0].message.tool_calls,
290
+ }
291
+
292
+ Args:
293
+ normalized: The normalized messages list
294
+ is_new_turn: If True, clear reasoning_content from historical messages
295
+ to save network bandwidth
296
+
297
+ Returns:
298
+ The processed messages list
299
+ """
300
+ if not normalized:
301
+ return normalized
302
+
303
+ # Find the last user message index to determine the current turn boundary
304
+ last_user_idx = -1
305
+ for idx in range(len(normalized) - 1, -1, -1):
306
+ if normalized[idx].get("role") == "user":
307
+ last_user_idx = idx
308
+ break
309
+
310
+ if is_new_turn and last_user_idx > 0:
311
+ # Clear reasoning_content from messages before the last user message
312
+ # This is optional but recommended by DeepSeek to save bandwidth
313
+ for idx in range(last_user_idx):
314
+ msg = normalized[idx]
315
+ if msg.get("role") == "assistant" and "reasoning_content" in msg:
316
+ # Set to None instead of deleting to match DeepSeek's example
317
+ msg["reasoning_content"] = None
318
+
319
+ # Validate: ensure all assistant messages with tool_calls have reasoning_content
320
+ # within the current turn (after last_user_idx)
321
+ for idx in range(max(0, last_user_idx), len(normalized)):
322
+ msg = normalized[idx]
323
+ if msg.get("role") == "assistant" and msg.get("tool_calls"):
324
+ if "reasoning_content" not in msg:
325
+ # This is a problem - DeepSeek requires reasoning_content for tool_calls
326
+ logger.warning(
327
+ f"[deepseek] Assistant message at index {idx} has tool_calls "
328
+ f"but missing reasoning_content - this may cause API errors"
329
+ )
330
+
331
+ return normalized
332
+
333
+
271
334
  def normalize_messages_for_api(
272
335
  messages: List[Union[UserMessage, AssistantMessage, ProgressMessage]],
273
336
  protocol: str = "anthropic",
274
337
  tool_mode: str = "native",
338
+ thinking_mode: Optional[str] = None,
275
339
  ) -> List[Dict[str, Any]]:
276
340
  """Normalize messages for API submission.
277
341
 
278
342
  Progress messages are filtered out as they are not sent to the API.
343
+
344
+ For DeepSeek thinking mode, this function ensures reasoning_content is properly
345
+ included in assistant messages that contain tool_calls, as required by the API.
279
346
  """
280
347
 
281
348
  def _msg_type(msg: Any) -> Optional[str]:
@@ -318,58 +385,6 @@ def normalize_messages_for_api(
318
385
  return meta_dict
319
386
  return {}
320
387
 
321
- def _block_type(block: Any) -> Optional[str]:
322
- if hasattr(block, "type"):
323
- return getattr(block, "type", None)
324
- if isinstance(block, dict):
325
- return block.get("type")
326
- return None
327
-
328
- def _block_attr(block: Any, attr: str, default: Any = None) -> Any:
329
- if hasattr(block, attr):
330
- return getattr(block, attr, default)
331
- if isinstance(block, dict):
332
- return block.get(attr, default)
333
- return default
334
-
335
- def _flatten_blocks_to_text(blocks: List[Any]) -> str:
336
- parts: List[str] = []
337
- for blk in blocks:
338
- btype = _block_type(blk)
339
- if btype == "text":
340
- text = _block_attr(blk, "text") or _block_attr(blk, "content") or ""
341
- if text:
342
- parts.append(str(text))
343
- elif btype == "tool_result":
344
- text = _block_attr(blk, "text") or _block_attr(blk, "content") or ""
345
- tool_id = _block_attr(blk, "tool_use_id") or _block_attr(blk, "id")
346
- prefix = "Tool error" if _block_attr(blk, "is_error") else "Tool result"
347
- label = f"{prefix}{f' ({tool_id})' if tool_id else ''}"
348
- parts.append(f"{label}: {text}" if text else label)
349
- elif btype == "tool_use":
350
- name = _block_attr(blk, "name") or ""
351
- input_data = _block_attr(blk, "input")
352
- input_preview = ""
353
- if input_data not in (None, {}):
354
- try:
355
- input_preview = json.dumps(input_data)
356
- except (TypeError, ValueError):
357
- input_preview = str(input_data)
358
- tool_id = _block_attr(blk, "tool_use_id") or _block_attr(blk, "id")
359
- desc = "Tool call"
360
- if name:
361
- desc += f" {name}"
362
- if tool_id:
363
- desc += f" ({tool_id})"
364
- if input_preview:
365
- desc += f": {input_preview}"
366
- parts.append(desc)
367
- else:
368
- text = _block_attr(blk, "text") or _block_attr(blk, "content") or ""
369
- if text:
370
- parts.append(str(text))
371
- return "\n".join(p for p in parts if p)
372
-
373
388
  effective_tool_mode = (tool_mode or "native").lower()
374
389
  if effective_tool_mode not in {"native", "text"}:
375
390
  effective_tool_mode = "native"
@@ -426,7 +441,9 @@ def normalize_messages_for_api(
426
441
  if block_type == "tool_result":
427
442
  tool_results_seen += 1
428
443
  # Skip tool_result blocks that lack a preceding tool_use
429
- tool_id = getattr(block, "tool_use_id", None) or getattr(block, "id", None)
444
+ tool_id = getattr(block, "tool_use_id", None) or getattr(
445
+ block, "id", None
446
+ )
430
447
  if not tool_id:
431
448
  skipped_tool_results_no_call += 1
432
449
  continue
@@ -486,19 +503,35 @@ def normalize_messages_for_api(
486
503
  mapped = _content_block_to_openai(block)
487
504
  if mapped:
488
505
  assistant_openai_msgs.append(mapped)
489
- if text_parts:
490
- assistant_openai_msgs.append(
491
- {"role": "assistant", "content": "\n".join(text_parts)}
492
- )
493
506
  if tool_calls:
507
+ # For DeepSeek thinking mode, we must include reasoning_content
508
+ # in the assistant message that contains tool_calls
509
+ tool_call_msg: Dict[str, Any] = {
510
+ "role": "assistant",
511
+ "content": "\n".join(text_parts) if text_parts else None,
512
+ "tool_calls": tool_calls,
513
+ }
514
+ # Add reasoning_content if present (required for DeepSeek thinking mode)
515
+ reasoning_content = meta.get("reasoning_content") if meta else None
516
+ if reasoning_content is not None:
517
+ tool_call_msg["reasoning_content"] = reasoning_content
518
+ logger.debug(
519
+ f"[normalize_messages_for_api] Added reasoning_content to "
520
+ f"tool_call message (len={len(str(reasoning_content))})"
521
+ )
522
+ elif thinking_mode == "deepseek":
523
+ logger.warning(
524
+ f"[normalize_messages_for_api] DeepSeek mode: assistant "
525
+ f"message with tool_calls but no reasoning_content in metadata. "
526
+ f"meta_keys={list(meta.keys()) if meta else []}"
527
+ )
528
+ assistant_openai_msgs.append(tool_call_msg)
529
+ elif text_parts:
494
530
  assistant_openai_msgs.append(
495
- {
496
- "role": "assistant",
497
- "content": None,
498
- "tool_calls": tool_calls,
499
- }
531
+ {"role": "assistant", "content": "\n".join(text_parts)}
500
532
  )
501
- if meta and assistant_openai_msgs:
533
+ # For non-tool-call messages, add reasoning metadata to the last message
534
+ if meta and assistant_openai_msgs and not tool_calls:
502
535
  for key in ("reasoning_content", "reasoning_details", "reasoning"):
503
536
  if key in meta and meta[key] is not None:
504
537
  assistant_openai_msgs[-1][key] = meta[key]
@@ -515,6 +548,7 @@ def normalize_messages_for_api(
515
548
 
516
549
  logger.debug(
517
550
  f"[normalize_messages_for_api] protocol={protocol} tool_mode={effective_tool_mode} "
551
+ f"thinking_mode={thinking_mode} "
518
552
  f"input_msgs={len(messages)} normalized={len(normalized)} "
519
553
  f"tool_results_seen={tool_results_seen} tool_uses_seen={tool_uses_seen} "
520
554
  f"tool_result_positions={len(tool_result_positions)} "
@@ -523,6 +557,11 @@ def normalize_messages_for_api(
523
557
  f"skipped_tool_uses_no_id={skipped_tool_uses_no_id} "
524
558
  f"skipped_tool_results_no_call={skipped_tool_results_no_call}"
525
559
  )
560
+
561
+ # Apply DeepSeek-specific reasoning_content handling
562
+ if thinking_mode == "deepseek":
563
+ normalized = _apply_deepseek_reasoning_content(normalized, is_new_turn=False)
564
+
526
565
  return normalized
527
566
 
528
567
 
@@ -11,8 +11,7 @@ from __future__ import annotations
11
11
 
12
12
  import re
13
13
  from pathlib import Path
14
- from typing import Dict, List, Optional, Set, Tuple
15
- from functools import lru_cache
14
+ from typing import Any, Dict, List, Optional, Set, Tuple
16
15
 
17
16
  from ripperdoc.utils.git_utils import (
18
17
  get_git_root,
@@ -287,7 +286,7 @@ def _compile_pattern(pattern: str) -> re.Pattern[str]:
287
286
  while j < len(pattern) and pattern[j] != "]":
288
287
  j += 1
289
288
  if j < len(pattern):
290
- regex += pattern[i:j + 1]
289
+ regex += pattern[i : j + 1]
291
290
  i = j
292
291
  else:
293
292
  regex += re.escape(c)
@@ -361,7 +360,7 @@ class IgnoreFilter:
361
360
 
362
361
  return result
363
362
 
364
- def test(self, path: str) -> Dict[str, any]:
363
+ def test(self, path: str) -> Dict[str, Any]:
365
364
  """Check if a path should be ignored and return details.
366
365
 
367
366
  Returns:
@@ -369,7 +368,7 @@ class IgnoreFilter:
369
368
  """
370
369
  path = path.replace("\\", "/").strip("/")
371
370
 
372
- result = {"ignored": False, "rule": None}
371
+ result: Dict[str, Any] = {"ignored": False, "rule": None}
373
372
 
374
373
  for pattern, is_negation in self._patterns:
375
374
  if pattern.search(path):
@@ -384,7 +383,9 @@ class IgnoreFilter:
384
383
  # =============================================================================
385
384
 
386
385
 
387
- def parse_ignore_pattern(pattern: str, settings_path: Optional[Path] = None) -> Tuple[str, Optional[Path]]:
386
+ def parse_ignore_pattern(
387
+ pattern: str, settings_path: Optional[Path] = None
388
+ ) -> Tuple[str, Optional[Path]]:
388
389
  """Parse an ignore pattern and return (relative_pattern, root_path).
389
390
 
390
391
  Supports prefixes:
@@ -504,6 +505,7 @@ def is_path_ignored(
504
505
  file_path = Path(file_path)
505
506
  if not file_path.is_absolute():
506
507
  from ripperdoc.utils.safe_get_cwd import safe_get_cwd
508
+
507
509
  file_path = Path(safe_get_cwd()) / file_path
508
510
 
509
511
  file_path = file_path.resolve()
@@ -513,6 +515,7 @@ def is_path_ignored(
513
515
  root_path = get_git_root(file_path.parent)
514
516
  if root_path is None:
515
517
  from ripperdoc.utils.safe_get_cwd import safe_get_cwd
518
+
516
519
  root_path = Path(safe_get_cwd())
517
520
 
518
521
  root_path = root_path.resolve()
@@ -629,12 +632,35 @@ def check_path_for_tool(
629
632
  # Check if it's a binary/media file
630
633
  suffix = file_path.suffix.lower()
631
634
  binary_extensions = {
632
- ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp",
633
- ".mp4", ".avi", ".mkv", ".mov", ".mp3", ".wav", ".flac",
634
- ".zip", ".tar", ".gz", ".7z", ".rar",
635
- ".exe", ".dll", ".so", ".dylib",
636
- ".db", ".sqlite", ".parquet",
637
- ".ttf", ".otf", ".woff",
635
+ ".png",
636
+ ".jpg",
637
+ ".jpeg",
638
+ ".gif",
639
+ ".bmp",
640
+ ".ico",
641
+ ".webp",
642
+ ".mp4",
643
+ ".avi",
644
+ ".mkv",
645
+ ".mov",
646
+ ".mp3",
647
+ ".wav",
648
+ ".flac",
649
+ ".zip",
650
+ ".tar",
651
+ ".gz",
652
+ ".7z",
653
+ ".rar",
654
+ ".exe",
655
+ ".dll",
656
+ ".so",
657
+ ".dylib",
658
+ ".db",
659
+ ".sqlite",
660
+ ".parquet",
661
+ ".ttf",
662
+ ".otf",
663
+ ".woff",
638
664
  }
639
665
  if suffix in binary_extensions:
640
666
  reasons.append("binary/media file")
@@ -51,7 +51,8 @@ def _resolve_path(raw_path: str, cwd: str) -> Path:
51
51
  except (OSError, ValueError) as exc:
52
52
  logger.warning(
53
53
  "[path_validation] Failed to resolve path: %s: %s",
54
- type(exc).__name__, exc,
54
+ type(exc).__name__,
55
+ exc,
55
56
  extra={"raw_path": raw_path, "cwd": cwd},
56
57
  )
57
58
  return candidate