ripperdoc 0.2.10__py3-none-any.whl → 0.3.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 (70) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +164 -57
  3. ripperdoc/cli/commands/__init__.py +4 -0
  4. ripperdoc/cli/commands/agents_cmd.py +3 -7
  5. ripperdoc/cli/commands/doctor_cmd.py +29 -0
  6. ripperdoc/cli/commands/memory_cmd.py +2 -1
  7. ripperdoc/cli/commands/models_cmd.py +61 -5
  8. ripperdoc/cli/commands/resume_cmd.py +1 -0
  9. ripperdoc/cli/commands/skills_cmd.py +103 -0
  10. ripperdoc/cli/commands/stats_cmd.py +4 -4
  11. ripperdoc/cli/commands/status_cmd.py +10 -0
  12. ripperdoc/cli/commands/tasks_cmd.py +6 -3
  13. ripperdoc/cli/commands/themes_cmd.py +139 -0
  14. ripperdoc/cli/ui/file_mention_completer.py +63 -13
  15. ripperdoc/cli/ui/helpers.py +6 -3
  16. ripperdoc/cli/ui/interrupt_handler.py +34 -0
  17. ripperdoc/cli/ui/panels.py +13 -8
  18. ripperdoc/cli/ui/rich_ui.py +451 -32
  19. ripperdoc/cli/ui/spinner.py +68 -5
  20. ripperdoc/cli/ui/tool_renderers.py +10 -9
  21. ripperdoc/cli/ui/wizard.py +18 -11
  22. ripperdoc/core/agents.py +4 -0
  23. ripperdoc/core/config.py +235 -0
  24. ripperdoc/core/default_tools.py +1 -0
  25. ripperdoc/core/hooks/llm_callback.py +0 -1
  26. ripperdoc/core/hooks/manager.py +6 -0
  27. ripperdoc/core/permissions.py +82 -5
  28. ripperdoc/core/providers/openai.py +55 -9
  29. ripperdoc/core/query.py +349 -108
  30. ripperdoc/core/query_utils.py +17 -14
  31. ripperdoc/core/skills.py +1 -0
  32. ripperdoc/core/theme.py +298 -0
  33. ripperdoc/core/tool.py +8 -3
  34. ripperdoc/protocol/__init__.py +14 -0
  35. ripperdoc/protocol/models.py +300 -0
  36. ripperdoc/protocol/stdio.py +1453 -0
  37. ripperdoc/tools/background_shell.py +49 -5
  38. ripperdoc/tools/bash_tool.py +75 -9
  39. ripperdoc/tools/file_edit_tool.py +98 -29
  40. ripperdoc/tools/file_read_tool.py +139 -8
  41. ripperdoc/tools/file_write_tool.py +46 -3
  42. ripperdoc/tools/grep_tool.py +98 -8
  43. ripperdoc/tools/lsp_tool.py +9 -15
  44. ripperdoc/tools/multi_edit_tool.py +26 -3
  45. ripperdoc/tools/skill_tool.py +52 -1
  46. ripperdoc/tools/task_tool.py +33 -8
  47. ripperdoc/utils/file_watch.py +12 -6
  48. ripperdoc/utils/image_utils.py +125 -0
  49. ripperdoc/utils/log.py +30 -3
  50. ripperdoc/utils/lsp.py +9 -3
  51. ripperdoc/utils/mcp.py +80 -18
  52. ripperdoc/utils/message_formatting.py +2 -2
  53. ripperdoc/utils/messages.py +177 -32
  54. ripperdoc/utils/pending_messages.py +50 -0
  55. ripperdoc/utils/permissions/shell_command_validation.py +3 -3
  56. ripperdoc/utils/permissions/tool_permission_utils.py +9 -3
  57. ripperdoc/utils/platform.py +198 -0
  58. ripperdoc/utils/session_heatmap.py +1 -3
  59. ripperdoc/utils/session_history.py +2 -2
  60. ripperdoc/utils/session_stats.py +1 -0
  61. ripperdoc/utils/shell_utils.py +8 -5
  62. ripperdoc/utils/todo.py +0 -6
  63. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/METADATA +49 -17
  64. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/RECORD +68 -61
  65. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/WHEEL +1 -1
  66. ripperdoc/sdk/__init__.py +0 -9
  67. ripperdoc/sdk/client.py +0 -408
  68. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/entry_points.txt +0 -0
  69. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/licenses/LICENSE +0 -0
  70. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/top_level.txt +0 -0
ripperdoc/sdk/client.py DELETED
@@ -1,408 +0,0 @@
1
- """Headless Python SDK for Ripperdoc.
2
-
3
- `query` helper for simple calls and a `RipperdocClient` for long-lived
4
- sessions that keep conversation history.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import asyncio
10
- import os
11
- import time
12
- import uuid
13
- from dataclasses import dataclass, field
14
- from pathlib import Path
15
- from typing import (
16
- Any,
17
- AsyncIterator,
18
- Awaitable,
19
- Callable,
20
- Dict,
21
- List,
22
- Optional,
23
- Sequence,
24
- Tuple,
25
- Union,
26
- )
27
-
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
31
- from ripperdoc.core.query import QueryContext, query as _core_query
32
- from ripperdoc.core.permissions import PermissionResult
33
- from ripperdoc.core.system_prompt import build_system_prompt
34
- from ripperdoc.core.skills import build_skill_summary, load_all_skills
35
- from ripperdoc.core.tool import Tool
36
- from ripperdoc.tools.task_tool import TaskTool
37
- from ripperdoc.tools.mcp_tools import load_dynamic_mcp_tools_async, merge_tools_with_dynamic
38
- from ripperdoc.utils.memory import build_memory_instructions
39
- from ripperdoc.utils.messages import (
40
- AssistantMessage,
41
- ProgressMessage,
42
- UserMessage,
43
- create_assistant_message,
44
- create_user_message,
45
- )
46
- from ripperdoc.utils.mcp import (
47
- format_mcp_instructions,
48
- load_mcp_servers_async,
49
- shutdown_mcp_runtime,
50
- )
51
- from ripperdoc.utils.log import get_logger
52
-
53
- MessageType = Union[UserMessage, AssistantMessage, ProgressMessage]
54
- PermissionChecker = Callable[
55
- [Tool[Any, Any], Any],
56
- Union[
57
- PermissionResult,
58
- Dict[str, Any],
59
- Tuple[bool, Optional[str]],
60
- bool,
61
- Awaitable[Union[PermissionResult, Dict[str, Any], Tuple[bool, Optional[str]], bool]],
62
- ],
63
- ]
64
- QueryRunner = Callable[
65
- [
66
- List[MessageType],
67
- str,
68
- Dict[str, str],
69
- QueryContext,
70
- Optional[PermissionChecker],
71
- ],
72
- AsyncIterator[MessageType],
73
- ]
74
-
75
- _END_OF_STREAM = object()
76
-
77
- logger = get_logger()
78
-
79
-
80
- def _coerce_to_path(path: Union[str, Path]) -> Path:
81
- return path if isinstance(path, Path) else Path(path)
82
-
83
-
84
- @dataclass
85
- class RipperdocOptions:
86
- """Configuration for SDK usage."""
87
-
88
- tools: Optional[Sequence[Tool[Any, Any]]] = None
89
- allowed_tools: Optional[Sequence[str]] = None
90
- disallowed_tools: Optional[Sequence[str]] = None
91
- yolo_mode: bool = False
92
- verbose: bool = False
93
- model: str = "main"
94
- max_thinking_tokens: int = 0
95
- context: Dict[str, str] = field(default_factory=dict)
96
- system_prompt: Optional[str] = None
97
- additional_instructions: Optional[Union[str, Sequence[str]]] = None
98
- permission_checker: Optional[PermissionChecker] = None
99
- cwd: Optional[Union[str, Path]] = None
100
-
101
- def build_tools(self) -> List[Tool[Any, Any]]:
102
- """Create the tool set with allow/deny filters applied."""
103
- base_tools = list(self.tools) if self.tools is not None else get_default_tools()
104
- allowed = set(self.allowed_tools) if self.allowed_tools is not None else None
105
- disallowed = set(self.disallowed_tools or [])
106
-
107
- filtered: List[Tool[Any, Any]] = []
108
- for tool in base_tools:
109
- name = getattr(tool, "name", tool.__class__.__name__)
110
- if allowed is not None and name not in allowed:
111
- continue
112
- if name in disallowed:
113
- continue
114
- filtered.append(tool)
115
-
116
- if allowed is not None and not filtered:
117
- raise ValueError("No tools remain after applying allowed_tools/disallowed_tools.")
118
-
119
- # The default Task tool captures the original base tools. If filters are
120
- # applied, recreate it so the subagent only sees the filtered set.
121
- if (self.allowed_tools or self.disallowed_tools) and self.tools is None:
122
- has_task = any(getattr(tool, "name", None) == "Task" for tool in filtered)
123
- if has_task:
124
- filtered_base = [tool for tool in filtered if getattr(tool, "name", None) != "Task"]
125
-
126
- def _filtered_base_provider() -> List[Tool[Any, Any]]:
127
- return filtered_base
128
-
129
- filtered = [
130
- (
131
- TaskTool(_filtered_base_provider)
132
- if getattr(tool, "name", None) == "Task"
133
- else tool
134
- )
135
- for tool in filtered
136
- ]
137
-
138
- return filtered
139
-
140
- def extra_instructions(self) -> List[str]:
141
- """Normalize additional instructions to a list."""
142
- if self.additional_instructions is None:
143
- return []
144
- if isinstance(self.additional_instructions, str):
145
- return [self.additional_instructions]
146
- return [text for text in self.additional_instructions if text]
147
-
148
-
149
- class RipperdocClient:
150
- """Persistent Ripperdoc session with conversation history."""
151
-
152
- def __init__(
153
- self,
154
- options: Optional[RipperdocOptions] = None,
155
- query_runner: Optional[QueryRunner] = None,
156
- ) -> None:
157
- self.options = options or RipperdocOptions()
158
- self._tools = self.options.build_tools()
159
- self._query_runner = query_runner or _core_query
160
-
161
- self._history: List[MessageType] = []
162
- self._queue: asyncio.Queue = asyncio.Queue()
163
- self._current_task: Optional[asyncio.Task] = None
164
- self._current_context: Optional[QueryContext] = None
165
- self._connected = False
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
171
-
172
- @property
173
- def tools(self) -> List[Tool[Any, Any]]:
174
- return self._tools
175
-
176
- @property
177
- def history(self) -> List[MessageType]:
178
- return list(self._history)
179
-
180
- async def __aenter__(self) -> "RipperdocClient":
181
- await self.connect()
182
- return self
183
-
184
- async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None: # type: ignore[override]
185
- await self.disconnect()
186
-
187
- async def connect(self, prompt: Optional[str] = None) -> None:
188
- """Prepare the session and optionally send an initial prompt."""
189
- if not self._connected:
190
- if self.options.cwd is not None:
191
- self._previous_cwd = Path.cwd()
192
- os.chdir(_coerce_to_path(self.options.cwd))
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
- )
210
-
211
- if prompt:
212
- await self.query(prompt)
213
-
214
- async def disconnect(self) -> None:
215
- """Tear down the session and restore the working directory."""
216
- if self._current_context:
217
- self._current_context.abort_controller.set()
218
-
219
- if self._current_task and not self._current_task.done():
220
- self._current_task.cancel()
221
- try:
222
- await self._current_task
223
- except asyncio.CancelledError:
224
- pass
225
-
226
- if self._previous_cwd:
227
- os.chdir(self._previous_cwd)
228
- self._previous_cwd = None
229
-
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
250
- await shutdown_mcp_runtime()
251
-
252
- async def query(self, prompt: str) -> None:
253
- """Send a prompt and start streaming the response."""
254
- if self._current_task and not self._current_task.done():
255
- raise RuntimeError(
256
- "A query is already in progress; wait for it to finish or interrupt it."
257
- )
258
-
259
- if not self._connected:
260
- await self.connect()
261
-
262
- self._queue = asyncio.Queue()
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
-
279
- user_message = create_user_message(prompt)
280
- history = list(self._history) + [user_message]
281
- self._history.append(user_message)
282
-
283
- system_prompt = await self._build_system_prompt(prompt, hook_instructions)
284
- context = dict(self.options.context)
285
-
286
- query_context = QueryContext(
287
- tools=self._tools,
288
- max_thinking_tokens=self.options.max_thinking_tokens,
289
- yolo_mode=self.options.yolo_mode,
290
- model=self.options.model,
291
- verbose=self.options.verbose,
292
- )
293
- self._current_context = query_context
294
-
295
- async def _runner() -> None:
296
- try:
297
- async for message in self._query_runner(
298
- history,
299
- system_prompt,
300
- context,
301
- query_context,
302
- self.options.permission_checker,
303
- ):
304
- if getattr(message, "type", None) in ("user", "assistant"):
305
- self._history.append(message) # type: ignore[arg-type]
306
- await self._queue.put(message)
307
- finally:
308
- await self._queue.put(_END_OF_STREAM)
309
-
310
- self._current_task = asyncio.create_task(_runner())
311
-
312
- async def receive_messages(self) -> AsyncIterator[MessageType]:
313
- """Yield messages for the active query."""
314
- if self._current_task is None:
315
- raise RuntimeError("No active query to receive messages from.")
316
-
317
- while True:
318
- message = await self._queue.get()
319
- if message is _END_OF_STREAM:
320
- break
321
- yield message # type: ignore[misc]
322
-
323
- async def receive_response(self) -> AsyncIterator[MessageType]:
324
- """Alias for receive_messages."""
325
- async for message in self.receive_messages():
326
- yield message
327
-
328
- async def interrupt(self) -> None:
329
- """Request cancellation of the active query."""
330
- if self._current_context:
331
- self._current_context.abort_controller.set()
332
-
333
- if self._current_task and not self._current_task.done():
334
- self._current_task.cancel()
335
- try:
336
- await self._current_task
337
- except asyncio.CancelledError:
338
- pass
339
-
340
- await self._queue.put(_END_OF_STREAM)
341
-
342
- async def _build_system_prompt(
343
- self, user_prompt: str, hook_instructions: Optional[List[str]] = None
344
- ) -> str:
345
- if self.options.system_prompt:
346
- return self.options.system_prompt
347
-
348
- instructions: List[str] = []
349
- project_path = _coerce_to_path(self.options.cwd or Path.cwd())
350
- skill_result = load_all_skills(project_path)
351
- for err in skill_result.errors:
352
- logger.warning(
353
- "[skills] Failed to load skill",
354
- extra={"path": str(err.path), "reason": err.reason},
355
- )
356
- skill_instructions = build_skill_summary(skill_result.skills)
357
- if skill_instructions:
358
- instructions.append(skill_instructions)
359
- instructions.extend(self.options.extra_instructions())
360
- memory = build_memory_instructions()
361
- if memory:
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])
367
-
368
- dynamic_tools = await load_dynamic_mcp_tools_async(project_path)
369
- if dynamic_tools:
370
- self._tools = merge_tools_with_dynamic(self._tools, dynamic_tools)
371
-
372
- servers = await load_mcp_servers_async(project_path)
373
- mcp_instructions = format_mcp_instructions(servers)
374
-
375
- return build_system_prompt(
376
- self._tools,
377
- user_prompt,
378
- dict(self.options.context),
379
- instructions or None,
380
- mcp_instructions=mcp_instructions,
381
- )
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
-
393
-
394
- async def query(
395
- prompt: str,
396
- options: Optional[RipperdocOptions] = None,
397
- query_runner: Optional[QueryRunner] = None,
398
- ) -> AsyncIterator[MessageType]:
399
- """One-shot helper: run a prompt in a fresh session."""
400
- client = RipperdocClient(options=options, query_runner=query_runner)
401
- await client.connect()
402
- await client.query(prompt)
403
-
404
- try:
405
- async for message in client.receive_messages():
406
- yield message
407
- finally:
408
- await client.disconnect()