ripperdoc 0.2.9__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 (45) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +235 -14
  3. ripperdoc/cli/commands/__init__.py +2 -0
  4. ripperdoc/cli/commands/agents_cmd.py +132 -5
  5. ripperdoc/cli/commands/clear_cmd.py +8 -0
  6. ripperdoc/cli/commands/exit_cmd.py +1 -0
  7. ripperdoc/cli/commands/models_cmd.py +3 -3
  8. ripperdoc/cli/commands/resume_cmd.py +4 -0
  9. ripperdoc/cli/commands/stats_cmd.py +244 -0
  10. ripperdoc/cli/ui/panels.py +1 -0
  11. ripperdoc/cli/ui/rich_ui.py +295 -24
  12. ripperdoc/cli/ui/spinner.py +30 -18
  13. ripperdoc/cli/ui/thinking_spinner.py +1 -2
  14. ripperdoc/cli/ui/wizard.py +6 -8
  15. ripperdoc/core/agents.py +10 -3
  16. ripperdoc/core/config.py +3 -6
  17. ripperdoc/core/default_tools.py +90 -10
  18. ripperdoc/core/hooks/events.py +4 -0
  19. ripperdoc/core/hooks/llm_callback.py +59 -0
  20. ripperdoc/core/permissions.py +78 -4
  21. ripperdoc/core/providers/openai.py +29 -19
  22. ripperdoc/core/query.py +192 -31
  23. ripperdoc/core/tool.py +9 -4
  24. ripperdoc/sdk/client.py +77 -2
  25. ripperdoc/tools/background_shell.py +305 -134
  26. ripperdoc/tools/bash_tool.py +42 -13
  27. ripperdoc/tools/file_edit_tool.py +159 -50
  28. ripperdoc/tools/file_read_tool.py +20 -0
  29. ripperdoc/tools/file_write_tool.py +7 -8
  30. ripperdoc/tools/lsp_tool.py +615 -0
  31. ripperdoc/tools/task_tool.py +514 -65
  32. ripperdoc/utils/conversation_compaction.py +1 -1
  33. ripperdoc/utils/file_watch.py +206 -3
  34. ripperdoc/utils/lsp.py +806 -0
  35. ripperdoc/utils/message_formatting.py +5 -2
  36. ripperdoc/utils/messages.py +21 -1
  37. ripperdoc/utils/permissions/tool_permission_utils.py +174 -15
  38. ripperdoc/utils/session_heatmap.py +244 -0
  39. ripperdoc/utils/session_stats.py +293 -0
  40. {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/METADATA +8 -2
  41. {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/RECORD +45 -39
  42. {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/WHEEL +0 -0
  43. {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/entry_points.txt +0 -0
  44. {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/licenses/LICENSE +0 -0
  45. {ripperdoc-0.2.9.dist-info → ripperdoc-0.2.10.dist-info}/top_level.txt +0 -0
ripperdoc/core/agents.py CHANGED
@@ -10,6 +10,7 @@ from typing import Any, Dict, Iterable, List, Optional, Tuple
10
10
 
11
11
  import yaml
12
12
 
13
+ from ripperdoc.utils.coerce import parse_boolish
13
14
  from ripperdoc.utils.log import get_logger
14
15
  from ripperdoc.tools.ask_user_question_tool import AskUserQuestionTool
15
16
  from ripperdoc.tools.bash_output_tool import BashOutputTool
@@ -91,6 +92,7 @@ class AgentDefinition:
91
92
  model: Optional[str] = None
92
93
  color: Optional[str] = None
93
94
  filename: Optional[str] = None
95
+ fork_context: bool = False
94
96
 
95
97
 
96
98
  @dataclass
@@ -234,7 +236,7 @@ def _built_in_agents() -> List[AgentDefinition]:
234
236
  system_prompt=EXPLORE_AGENT_PROMPT,
235
237
  location=AgentLocation.BUILT_IN,
236
238
  color="green",
237
- model="task",
239
+ model="main",
238
240
  ),
239
241
  AgentDefinition(
240
242
  agent_type="plan",
@@ -324,8 +326,9 @@ def _parse_agent_file(
324
326
  return None, f"Failed to read agent file {path}: {exc}"
325
327
 
326
328
  frontmatter, body = _split_frontmatter(text)
327
- if "__error__" in frontmatter:
328
- return None, str(frontmatter["__error__"])
329
+ error = frontmatter.get("__error__")
330
+ if error is not None:
331
+ return None, str(error)
329
332
 
330
333
  agent_name = frontmatter.get("name")
331
334
  description = frontmatter.get("description")
@@ -339,6 +342,7 @@ def _parse_agent_file(
339
342
  color_value = frontmatter.get("color")
340
343
  model = model_value if isinstance(model_value, str) else None
341
344
  color = color_value if isinstance(color_value, str) else None
345
+ fork_context = parse_boolish(frontmatter.get("fork_context") or frontmatter.get("fork-context"))
342
346
 
343
347
  agent = AgentDefinition(
344
348
  agent_type=agent_name.strip(),
@@ -349,6 +353,7 @@ def _parse_agent_file(
349
353
  model=model,
350
354
  color=color,
351
355
  filename=path.stem,
356
+ fork_context=fork_context,
352
357
  )
353
358
  return agent, None
354
359
 
@@ -404,6 +409,8 @@ def summarize_agent(agent: AgentDefinition) -> str:
404
409
  tool_label = "all tools" if "*" in agent.tools else ", ".join(agent.tools)
405
410
  location = getattr(agent.location, "value", agent.location)
406
411
  details = [f"tools: {tool_label}"]
412
+ if agent.fork_context:
413
+ details.append("context: forked")
407
414
  if agent.model:
408
415
  details.append(f"model: {agent.model}")
409
416
  return f"- {agent.agent_type} ({location}): {agent.when_to_use} [{'; '.join(details)}]"
ripperdoc/core/config.py CHANGED
@@ -8,7 +8,7 @@ import json
8
8
  import os
9
9
  from pathlib import Path
10
10
  from typing import Any, Dict, Optional, Literal
11
- from pydantic import BaseModel, Field, field_validator, model_validator
11
+ from pydantic import BaseModel, Field, model_validator
12
12
  from enum import Enum
13
13
 
14
14
  from ripperdoc.utils.log import get_logger
@@ -122,8 +122,6 @@ class ModelPointers(BaseModel):
122
122
  """Pointers to different model profiles for different purposes."""
123
123
 
124
124
  main: str = "default"
125
- task: str = "default"
126
- reasoning: str = "default"
127
125
  quick: str = "default"
128
126
 
129
127
 
@@ -192,7 +190,6 @@ class ProjectConfig(BaseModel):
192
190
 
193
191
  # Project settings
194
192
  dont_crawl_directory: bool = False
195
- enable_architect_tool: bool = False
196
193
 
197
194
  # Trust
198
195
  has_trust_dialog_accepted: bool = False
@@ -517,7 +514,7 @@ class ConfigManager:
517
514
  return config
518
515
 
519
516
  def set_model_pointer(self, pointer: str, profile_name: str) -> GlobalConfig:
520
- """Point a logical model slot (e.g., main/task) to a profile name."""
517
+ """Point a logical model slot (e.g., main/quick) to a profile name."""
521
518
  if pointer not in ModelPointers.model_fields:
522
519
  raise ValueError(f"Unknown model pointer '{pointer}'.")
523
520
 
@@ -575,7 +572,7 @@ def delete_model_profile(name: str) -> GlobalConfig:
575
572
 
576
573
 
577
574
  def set_model_pointer(pointer: str, profile_name: str) -> GlobalConfig:
578
- """Update a model pointer (e.g., main/task) to target a profile."""
575
+ """Update a model pointer (e.g., main/quick) to target a profile."""
579
576
  return config_manager.set_model_pointer(pointer, profile_name)
580
577
 
581
578
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Any, List
5
+ from typing import Any, List, Optional
6
6
 
7
7
  from ripperdoc.core.tool import Tool
8
8
 
@@ -17,6 +17,7 @@ from ripperdoc.tools.file_write_tool import FileWriteTool
17
17
  from ripperdoc.tools.glob_tool import GlobTool
18
18
  from ripperdoc.tools.ls_tool import LSTool
19
19
  from ripperdoc.tools.grep_tool import GrepTool
20
+ from ripperdoc.tools.lsp_tool import LspTool
20
21
  from ripperdoc.tools.skill_tool import SkillTool
21
22
  from ripperdoc.tools.todo_tool import TodoReadTool, TodoWriteTool
22
23
  from ripperdoc.tools.ask_user_question_tool import AskUserQuestionTool
@@ -34,8 +35,73 @@ from ripperdoc.utils.log import get_logger
34
35
 
35
36
  logger = get_logger()
36
37
 
38
+ # Canonical tool names for --tools filtering
39
+ BUILTIN_TOOL_NAMES = [
40
+ "Bash",
41
+ "BashOutput",
42
+ "KillBash",
43
+ "Read",
44
+ "Edit",
45
+ "MultiEdit",
46
+ "NotebookEdit",
47
+ "Write",
48
+ "Glob",
49
+ "LS",
50
+ "Grep",
51
+ "LSP",
52
+ "Skill",
53
+ "TodoRead",
54
+ "TodoWrite",
55
+ "AskUserQuestion",
56
+ "EnterPlanMode",
57
+ "ExitPlanMode",
58
+ "ToolSearch",
59
+ "ListMcpServers",
60
+ "ListMcpResources",
61
+ "ReadMcpResource",
62
+ "Task",
63
+ ]
37
64
 
38
- def get_default_tools() -> List[Tool[Any, Any]]:
65
+
66
+ def filter_tools_by_names(
67
+ tools: List[Tool[Any, Any]], tool_names: List[str]
68
+ ) -> List[Tool[Any, Any]]:
69
+ """Filter a tool list to only include tools with matching names.
70
+
71
+ Args:
72
+ tools: The full list of tools to filter.
73
+ tool_names: List of tool names to include.
74
+
75
+ Returns:
76
+ Filtered list of tools. If Task is included, it's recreated with
77
+ the filtered base tools.
78
+ """
79
+ if not tool_names:
80
+ return []
81
+
82
+ name_set = set(tool_names)
83
+ filtered: List[Tool[Any, Any]] = []
84
+ has_task = False
85
+
86
+ for tool in tools:
87
+ tool_name = getattr(tool, "name", tool.__class__.__name__)
88
+ if tool_name in name_set:
89
+ if tool_name == "Task":
90
+ has_task = True
91
+ else:
92
+ filtered.append(tool)
93
+
94
+ # If Task is requested, recreate it with the filtered base tools
95
+ if has_task:
96
+ def _filtered_base_provider() -> List[Tool[Any, Any]]:
97
+ return [t for t in filtered if getattr(t, "name", None) != "Task"]
98
+
99
+ filtered.append(TaskTool(_filtered_base_provider))
100
+
101
+ return filtered
102
+
103
+
104
+ def get_default_tools(allowed_tools: Optional[List[str]] = None) -> List[Tool[Any, Any]]:
39
105
  """Construct the default tool set (base tools + Task subagent launcher)."""
40
106
  base_tools: List[Tool[Any, Any]] = [
41
107
  BashTool(),
@@ -49,6 +115,7 @@ def get_default_tools() -> List[Tool[Any, Any]]:
49
115
  GlobTool(),
50
116
  LSTool(),
51
117
  GrepTool(),
118
+ LspTool(),
52
119
  SkillTool(),
53
120
  TodoReadTool(),
54
121
  TodoWriteTool(),
@@ -86,12 +153,25 @@ def get_default_tools() -> List[Tool[Any, Any]]:
86
153
 
87
154
  task_tool = TaskTool(lambda: base_tools)
88
155
  all_tools = base_tools + [task_tool]
89
- logger.debug(
90
- "[default_tools] Built tool inventory",
91
- extra={
92
- "base_tools": len(base_tools),
93
- "dynamic_mcp_tools": len(dynamic_tools),
94
- "total_tools": len(all_tools),
95
- },
96
- )
156
+
157
+ # Apply allowed_tools filter if specified
158
+ if allowed_tools is not None:
159
+ all_tools = filter_tools_by_names(all_tools, allowed_tools)
160
+ logger.debug(
161
+ "[default_tools] Filtered tool inventory",
162
+ extra={
163
+ "allowed_tools": allowed_tools,
164
+ "filtered_tools": len(all_tools),
165
+ },
166
+ )
167
+ else:
168
+ logger.debug(
169
+ "[default_tools] Built tool inventory",
170
+ extra={
171
+ "base_tools": len(base_tools),
172
+ "dynamic_mcp_tools": len(dynamic_tools),
173
+ "total_tools": len(all_tools),
174
+ },
175
+ )
176
+
97
177
  return all_tools
@@ -515,6 +515,10 @@ class HookOutput(BaseModel):
515
515
  """Get updated input from PreToolUse hook."""
516
516
  if isinstance(self.hook_specific_output, PreToolUseHookOutput):
517
517
  return self.hook_specific_output.updated_input
518
+ if isinstance(self.hook_specific_output, PermissionRequestHookOutput):
519
+ decision = self.hook_specific_output.decision
520
+ if decision and decision.updated_input:
521
+ return decision.updated_input
518
522
  if isinstance(self.hook_specific_output, dict):
519
523
  return self.hook_specific_output.get("updatedInput")
520
524
  return None
@@ -0,0 +1,59 @@
1
+ """LLM callback helper for prompt-based hooks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from ripperdoc.core.hooks.executor import LLMCallback
8
+ from ripperdoc.core.query import query_llm
9
+ from ripperdoc.utils.log import get_logger
10
+ from ripperdoc.utils.messages import AssistantMessage, create_user_message
11
+
12
+ logger = get_logger()
13
+
14
+
15
+ def _extract_text(message: AssistantMessage) -> str:
16
+ content = message.message.content
17
+ if isinstance(content, str):
18
+ return content
19
+ if isinstance(content, list):
20
+ parts = []
21
+ for block in content:
22
+ text = getattr(block, "text", None) or (
23
+ block.get("text") if isinstance(block, dict) else None
24
+ )
25
+ if text:
26
+ parts.append(str(text))
27
+ return "\n".join(parts)
28
+ return ""
29
+
30
+
31
+ def build_hook_llm_callback(
32
+ *,
33
+ model: str = "quick",
34
+ max_thinking_tokens: int = 0,
35
+ system_prompt: Optional[str] = None,
36
+ ) -> LLMCallback:
37
+ """Build an async callback for prompt hooks using the configured model."""
38
+
39
+ async def _callback(prompt: str) -> str:
40
+ try:
41
+ assistant = await query_llm(
42
+ [create_user_message(prompt)],
43
+ system_prompt or "",
44
+ [],
45
+ max_thinking_tokens=max_thinking_tokens,
46
+ model=model,
47
+ stream=False,
48
+ )
49
+ return _extract_text(assistant).strip()
50
+ except Exception as exc:
51
+ logger.warning(
52
+ "[hooks] Prompt hook LLM callback failed: %s: %s",
53
+ type(exc).__name__,
54
+ exc,
55
+ )
56
+ return f"Prompt hook evaluation failed: {exc}"
57
+
58
+ return _callback
59
+
@@ -9,6 +9,7 @@ from pathlib import Path
9
9
  from typing import Any, Awaitable, Callable, Optional, Set
10
10
 
11
11
  from ripperdoc.core.config import config_manager
12
+ from ripperdoc.core.hooks.manager import hook_manager
12
13
  from ripperdoc.core.tool import Tool
13
14
  from ripperdoc.utils.permissions import PermissionDecision, ToolRule
14
15
  from ripperdoc.utils.log import get_logger
@@ -148,8 +149,9 @@ def make_permission_checker(
148
149
  return PermissionResult(result=True)
149
150
 
150
151
  try:
151
- if hasattr(tool, "needs_permissions") and not tool.needs_permissions(parsed_input):
152
- return PermissionResult(result=True)
152
+ needs_permission = True
153
+ if hasattr(tool, "needs_permissions"):
154
+ needs_permission = tool.needs_permissions(parsed_input)
153
155
  except (TypeError, AttributeError, ValueError) as exc:
154
156
  # Tool implementation error - log and deny for safety
155
157
  logger.warning(
@@ -166,10 +168,25 @@ def make_permission_checker(
166
168
  )
167
169
 
168
170
  allowed_tools = set(config.allowed_tools or [])
171
+
172
+ global_config = config_manager.get_global_config()
173
+ local_config = config_manager.get_project_local_config(project_path)
174
+
169
175
  allow_rules = {
170
- "Bash": set(config.bash_allow_rules or []) | session_tool_rules.get("Bash", set())
176
+ "Bash": (
177
+ set(config.bash_allow_rules or [])
178
+ | set(global_config.user_allow_rules or [])
179
+ | set(local_config.local_allow_rules or [])
180
+ | session_tool_rules.get("Bash", set())
181
+ )
182
+ }
183
+ deny_rules = {
184
+ "Bash": (
185
+ set(config.bash_deny_rules or [])
186
+ | set(global_config.user_deny_rules or [])
187
+ | set(local_config.local_deny_rules or [])
188
+ )
171
189
  }
172
- deny_rules = {"Bash": set(config.bash_deny_rules or [])}
173
190
  allowed_working_dirs = {
174
191
  str(project_path.resolve()),
175
192
  *[str(Path(p).resolve()) for p in config.working_directories or []],
@@ -219,6 +236,22 @@ def make_permission_checker(
219
236
  rule_suggestions=[ToolRule(tool_name=tool.name, rule_content=tool.name)],
220
237
  )
221
238
 
239
+ # If tool doesn't normally require permission (e.g., read-only Bash),
240
+ # enforce deny rules but otherwise skip prompting.
241
+ if not needs_permission:
242
+ if decision.behavior == "deny":
243
+ return PermissionResult(
244
+ result=False,
245
+ message=decision.message or f"Permission denied for tool '{tool.name}'.",
246
+ decision=decision,
247
+ )
248
+ return PermissionResult(
249
+ result=True,
250
+ message=decision.message,
251
+ updated_input=decision.updated_input,
252
+ decision=decision,
253
+ )
254
+
222
255
  if decision.behavior == "allow":
223
256
  return PermissionResult(
224
257
  result=True,
@@ -235,6 +268,47 @@ def make_permission_checker(
235
268
  )
236
269
 
237
270
  # Ask/passthrough flows prompt the user.
271
+ tool_input_dict = (
272
+ parsed_input.model_dump()
273
+ if hasattr(parsed_input, "model_dump")
274
+ else dict(parsed_input)
275
+ if isinstance(parsed_input, dict)
276
+ else {}
277
+ )
278
+ try:
279
+ hook_result = await hook_manager.run_permission_request_async(
280
+ tool.name, tool_input_dict
281
+ )
282
+ if hook_result.outputs:
283
+ updated_input = hook_result.updated_input or decision.updated_input
284
+ if hook_result.should_allow:
285
+ return PermissionResult(
286
+ result=True,
287
+ message=decision.message,
288
+ updated_input=updated_input,
289
+ decision=decision,
290
+ )
291
+ if hook_result.should_block or not hook_result.should_continue:
292
+ reason = (
293
+ hook_result.block_reason
294
+ or hook_result.stop_reason
295
+ or decision.message
296
+ or f"Permission denied for tool '{tool.name}'."
297
+ )
298
+ return PermissionResult(
299
+ result=False,
300
+ message=reason,
301
+ updated_input=updated_input,
302
+ decision=decision,
303
+ )
304
+ except (RuntimeError, ValueError, TypeError, OSError) as exc:
305
+ logger.warning(
306
+ "[permissions] PermissionRequest hook failed: %s: %s",
307
+ type(exc).__name__,
308
+ exc,
309
+ extra={"tool": getattr(tool, "name", None)},
310
+ )
311
+
238
312
  input_preview = _format_input_preview(parsed_input, tool_name=tool.name)
239
313
  prompt_lines = [
240
314
  f"{tool.name}",
@@ -300,9 +300,10 @@ class OpenAIClient(ProviderClient):
300
300
  if getattr(chunk, "usage", None):
301
301
  streamed_usage.update(openai_usage_tokens(chunk.usage))
302
302
 
303
- if not getattr(chunk, "choices", None):
303
+ choices = getattr(chunk, "choices", None)
304
+ if not choices or len(choices) == 0:
304
305
  continue
305
- delta = getattr(chunk.choices[0], "delta", None)
306
+ delta = getattr(choices[0], "delta", None)
306
307
  if not delta:
307
308
  continue
308
309
 
@@ -486,23 +487,32 @@ class OpenAIClient(ProviderClient):
486
487
  )
487
488
  finish_reason = "stream"
488
489
  else:
489
- choice = openai_response.choices[0]
490
- content_blocks = content_blocks_from_openai_choice(choice, tool_mode)
491
- finish_reason = cast(Optional[str], getattr(choice, "finish_reason", None))
492
- message_obj = getattr(choice, "message", None) or choice
493
- reasoning_content = getattr(message_obj, "reasoning_content", None)
494
- if reasoning_content:
495
- response_metadata["reasoning_content"] = reasoning_content
496
- reasoning_field = getattr(message_obj, "reasoning", None)
497
- if reasoning_field:
498
- response_metadata["reasoning"] = reasoning_field
499
- if "reasoning_content" not in response_metadata and isinstance(
500
- reasoning_field, str
501
- ):
502
- response_metadata["reasoning_content"] = reasoning_field
503
- reasoning_details = getattr(message_obj, "reasoning_details", None)
504
- if reasoning_details:
505
- response_metadata["reasoning_details"] = reasoning_details
490
+ response_choices = getattr(openai_response, "choices", None)
491
+ if not response_choices or len(response_choices) == 0:
492
+ logger.warning(
493
+ "[openai_client] Empty choices in response",
494
+ extra={"model": model_profile.model},
495
+ )
496
+ content_blocks = [{"type": "text", "text": ""}]
497
+ finish_reason = "error"
498
+ else:
499
+ choice = response_choices[0]
500
+ content_blocks = content_blocks_from_openai_choice(choice, tool_mode)
501
+ finish_reason = cast(Optional[str], getattr(choice, "finish_reason", None))
502
+ message_obj = getattr(choice, "message", None) or choice
503
+ reasoning_content = getattr(message_obj, "reasoning_content", None)
504
+ if reasoning_content:
505
+ response_metadata["reasoning_content"] = reasoning_content
506
+ reasoning_field = getattr(message_obj, "reasoning", None)
507
+ if reasoning_field:
508
+ response_metadata["reasoning"] = reasoning_field
509
+ if "reasoning_content" not in response_metadata and isinstance(
510
+ reasoning_field, str
511
+ ):
512
+ response_metadata["reasoning_content"] = reasoning_field
513
+ reasoning_details = getattr(message_obj, "reasoning_details", None)
514
+ if reasoning_details:
515
+ response_metadata["reasoning_details"] = reasoning_details
506
516
 
507
517
  if can_stream:
508
518
  if stream_reasoning_text: