ripperdoc 0.2.6__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.
- ripperdoc/__init__.py +3 -0
- ripperdoc/__main__.py +20 -0
- ripperdoc/cli/__init__.py +1 -0
- ripperdoc/cli/cli.py +405 -0
- ripperdoc/cli/commands/__init__.py +82 -0
- ripperdoc/cli/commands/agents_cmd.py +263 -0
- ripperdoc/cli/commands/base.py +19 -0
- ripperdoc/cli/commands/clear_cmd.py +18 -0
- ripperdoc/cli/commands/compact_cmd.py +23 -0
- ripperdoc/cli/commands/config_cmd.py +31 -0
- ripperdoc/cli/commands/context_cmd.py +144 -0
- ripperdoc/cli/commands/cost_cmd.py +82 -0
- ripperdoc/cli/commands/doctor_cmd.py +221 -0
- ripperdoc/cli/commands/exit_cmd.py +19 -0
- ripperdoc/cli/commands/help_cmd.py +20 -0
- ripperdoc/cli/commands/mcp_cmd.py +70 -0
- ripperdoc/cli/commands/memory_cmd.py +202 -0
- ripperdoc/cli/commands/models_cmd.py +413 -0
- ripperdoc/cli/commands/permissions_cmd.py +302 -0
- ripperdoc/cli/commands/resume_cmd.py +98 -0
- ripperdoc/cli/commands/status_cmd.py +167 -0
- ripperdoc/cli/commands/tasks_cmd.py +278 -0
- ripperdoc/cli/commands/todos_cmd.py +69 -0
- ripperdoc/cli/commands/tools_cmd.py +19 -0
- ripperdoc/cli/ui/__init__.py +1 -0
- ripperdoc/cli/ui/context_display.py +298 -0
- ripperdoc/cli/ui/helpers.py +22 -0
- ripperdoc/cli/ui/rich_ui.py +1557 -0
- ripperdoc/cli/ui/spinner.py +49 -0
- ripperdoc/cli/ui/thinking_spinner.py +128 -0
- ripperdoc/cli/ui/tool_renderers.py +298 -0
- ripperdoc/core/__init__.py +1 -0
- ripperdoc/core/agents.py +486 -0
- ripperdoc/core/commands.py +33 -0
- ripperdoc/core/config.py +559 -0
- ripperdoc/core/default_tools.py +88 -0
- ripperdoc/core/permissions.py +252 -0
- ripperdoc/core/providers/__init__.py +47 -0
- ripperdoc/core/providers/anthropic.py +250 -0
- ripperdoc/core/providers/base.py +265 -0
- ripperdoc/core/providers/gemini.py +615 -0
- ripperdoc/core/providers/openai.py +487 -0
- ripperdoc/core/query.py +1058 -0
- ripperdoc/core/query_utils.py +622 -0
- ripperdoc/core/skills.py +295 -0
- ripperdoc/core/system_prompt.py +431 -0
- ripperdoc/core/tool.py +240 -0
- ripperdoc/sdk/__init__.py +9 -0
- ripperdoc/sdk/client.py +333 -0
- ripperdoc/tools/__init__.py +1 -0
- ripperdoc/tools/ask_user_question_tool.py +431 -0
- ripperdoc/tools/background_shell.py +389 -0
- ripperdoc/tools/bash_output_tool.py +98 -0
- ripperdoc/tools/bash_tool.py +1016 -0
- ripperdoc/tools/dynamic_mcp_tool.py +428 -0
- ripperdoc/tools/enter_plan_mode_tool.py +226 -0
- ripperdoc/tools/exit_plan_mode_tool.py +153 -0
- ripperdoc/tools/file_edit_tool.py +346 -0
- ripperdoc/tools/file_read_tool.py +203 -0
- ripperdoc/tools/file_write_tool.py +205 -0
- ripperdoc/tools/glob_tool.py +179 -0
- ripperdoc/tools/grep_tool.py +370 -0
- ripperdoc/tools/kill_bash_tool.py +136 -0
- ripperdoc/tools/ls_tool.py +471 -0
- ripperdoc/tools/mcp_tools.py +591 -0
- ripperdoc/tools/multi_edit_tool.py +456 -0
- ripperdoc/tools/notebook_edit_tool.py +386 -0
- ripperdoc/tools/skill_tool.py +205 -0
- ripperdoc/tools/task_tool.py +379 -0
- ripperdoc/tools/todo_tool.py +494 -0
- ripperdoc/tools/tool_search_tool.py +380 -0
- ripperdoc/utils/__init__.py +1 -0
- ripperdoc/utils/bash_constants.py +51 -0
- ripperdoc/utils/bash_output_utils.py +43 -0
- ripperdoc/utils/coerce.py +34 -0
- ripperdoc/utils/context_length_errors.py +252 -0
- ripperdoc/utils/exit_code_handlers.py +241 -0
- ripperdoc/utils/file_watch.py +135 -0
- ripperdoc/utils/git_utils.py +274 -0
- ripperdoc/utils/json_utils.py +27 -0
- ripperdoc/utils/log.py +176 -0
- ripperdoc/utils/mcp.py +560 -0
- ripperdoc/utils/memory.py +253 -0
- ripperdoc/utils/message_compaction.py +676 -0
- ripperdoc/utils/messages.py +519 -0
- ripperdoc/utils/output_utils.py +258 -0
- ripperdoc/utils/path_ignore.py +677 -0
- ripperdoc/utils/path_utils.py +46 -0
- ripperdoc/utils/permissions/__init__.py +27 -0
- ripperdoc/utils/permissions/path_validation_utils.py +174 -0
- ripperdoc/utils/permissions/shell_command_validation.py +552 -0
- ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
- ripperdoc/utils/prompt.py +17 -0
- ripperdoc/utils/safe_get_cwd.py +31 -0
- ripperdoc/utils/sandbox_utils.py +38 -0
- ripperdoc/utils/session_history.py +260 -0
- ripperdoc/utils/session_usage.py +117 -0
- ripperdoc/utils/shell_token_utils.py +95 -0
- ripperdoc/utils/shell_utils.py +159 -0
- ripperdoc/utils/todo.py +203 -0
- ripperdoc/utils/token_estimation.py +34 -0
- ripperdoc-0.2.6.dist-info/METADATA +193 -0
- ripperdoc-0.2.6.dist-info/RECORD +107 -0
- ripperdoc-0.2.6.dist-info/WHEEL +5 -0
- ripperdoc-0.2.6.dist-info/entry_points.txt +3 -0
- ripperdoc-0.2.6.dist-info/licenses/LICENSE +53 -0
- ripperdoc-0.2.6.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""Notebook edit tool.
|
|
2
|
+
|
|
3
|
+
Allows performing insert/replace/delete operations on Jupyter notebook cells.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import random
|
|
9
|
+
import string
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from textwrap import dedent
|
|
12
|
+
from typing import AsyncGenerator, List, Optional
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
from ripperdoc.core.tool import (
|
|
16
|
+
Tool,
|
|
17
|
+
ToolUseContext,
|
|
18
|
+
ToolResult,
|
|
19
|
+
ToolOutput,
|
|
20
|
+
ToolUseExample,
|
|
21
|
+
ValidationResult,
|
|
22
|
+
)
|
|
23
|
+
from ripperdoc.utils.log import get_logger
|
|
24
|
+
from ripperdoc.utils.file_watch import record_snapshot
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
logger = get_logger()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _resolve_path(path_str: str) -> Path:
|
|
31
|
+
"""Return an absolute Path, interpreting relative paths from CWD."""
|
|
32
|
+
path = Path(path_str).expanduser()
|
|
33
|
+
return path if path.is_absolute() else Path.cwd() / path
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _generate_cell_id() -> str:
|
|
37
|
+
"""Generate a short random cell id."""
|
|
38
|
+
return "".join(random.choices(string.ascii_lowercase + string.digits, k=12))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
NOTEBOOK_EDIT_DESCRIPTION = dedent(
|
|
42
|
+
"""\
|
|
43
|
+
Replace, insert, or delete a specific cell in a Jupyter notebook (.ipynb file).
|
|
44
|
+
notebook_path must be an absolute path. cell_id may be a 0-based index or a cell id.
|
|
45
|
+
Use edit_mode=insert to add a new cell after the referenced cell (or at the start if omitted).
|
|
46
|
+
Use edit_mode=delete to delete the referenced cell. Defaults to edit_mode=replace.
|
|
47
|
+
|
|
48
|
+
Usage:
|
|
49
|
+
- cell_type: 'code' or 'markdown'. Required for insert; defaults to existing type for replace.
|
|
50
|
+
- new_source: New content for the cell.
|
|
51
|
+
- Edits are applied atomically; failures leave the file unchanged.
|
|
52
|
+
- Code cell replacements clear execution_count and outputs.
|
|
53
|
+
- Only use emojis if explicitly requested; avoid adding emojis otherwise.
|
|
54
|
+
"""
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class NotebookEditInput(BaseModel):
|
|
59
|
+
"""Input schema for NotebookEditTool."""
|
|
60
|
+
|
|
61
|
+
notebook_path: str = Field(description="Absolute path to the Jupyter notebook file to edit")
|
|
62
|
+
cell_id: Optional[str] = Field(
|
|
63
|
+
default=None,
|
|
64
|
+
description="Cell ID or 0-based index. For insert, omitted means insert at start.",
|
|
65
|
+
)
|
|
66
|
+
new_source: str = Field(description="New source content for the target cell")
|
|
67
|
+
cell_type: Optional[str] = Field(
|
|
68
|
+
default=None,
|
|
69
|
+
description="Cell type: 'code' or 'markdown'. Required for insert.",
|
|
70
|
+
)
|
|
71
|
+
edit_mode: Optional[str] = Field(
|
|
72
|
+
default="replace",
|
|
73
|
+
description="Edit mode: 'replace' (default), 'insert', or 'delete'.",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class NotebookEditOutput(BaseModel):
|
|
78
|
+
"""Output from notebook editing."""
|
|
79
|
+
|
|
80
|
+
new_source: str
|
|
81
|
+
cell_id: Optional[str] = None
|
|
82
|
+
cell_type: str
|
|
83
|
+
language: str
|
|
84
|
+
edit_mode: str
|
|
85
|
+
error: Optional[str] = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class NotebookEditTool(Tool[NotebookEditInput, NotebookEditOutput]):
|
|
89
|
+
"""Tool for editing Jupyter notebooks."""
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def name(self) -> str:
|
|
93
|
+
return "NotebookEdit"
|
|
94
|
+
|
|
95
|
+
async def description(self) -> str:
|
|
96
|
+
return NOTEBOOK_EDIT_DESCRIPTION
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def input_schema(self) -> type[NotebookEditInput]:
|
|
100
|
+
return NotebookEditInput
|
|
101
|
+
|
|
102
|
+
def input_examples(self) -> List[ToolUseExample]:
|
|
103
|
+
return [
|
|
104
|
+
ToolUseExample(
|
|
105
|
+
description="Replace a markdown cell by id",
|
|
106
|
+
example={
|
|
107
|
+
"notebook_path": "/repo/notebooks/analysis.ipynb",
|
|
108
|
+
"cell_id": "abc123",
|
|
109
|
+
"new_source": "# Updated overview\\nThis notebook analyzes revenue.",
|
|
110
|
+
"cell_type": "markdown",
|
|
111
|
+
"edit_mode": "replace",
|
|
112
|
+
},
|
|
113
|
+
),
|
|
114
|
+
ToolUseExample(
|
|
115
|
+
description="Insert a new code cell at the beginning",
|
|
116
|
+
example={
|
|
117
|
+
"notebook_path": "/repo/notebooks/analysis.ipynb",
|
|
118
|
+
"cell_type": "code",
|
|
119
|
+
"edit_mode": "insert",
|
|
120
|
+
"new_source": "import pandas as pd\\nimport numpy as np",
|
|
121
|
+
},
|
|
122
|
+
),
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
async def prompt(self, safe_mode: bool = False) -> str:
|
|
126
|
+
return NOTEBOOK_EDIT_DESCRIPTION
|
|
127
|
+
|
|
128
|
+
def is_read_only(self) -> bool:
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
def is_concurrency_safe(self) -> bool:
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
def needs_permissions(self, input_data: Optional[NotebookEditInput] = None) -> bool:
|
|
135
|
+
return True
|
|
136
|
+
|
|
137
|
+
async def validate_input(
|
|
138
|
+
self, input_data: NotebookEditInput, context: Optional[ToolUseContext] = None
|
|
139
|
+
) -> ValidationResult:
|
|
140
|
+
path = _resolve_path(input_data.notebook_path)
|
|
141
|
+
resolved_path = str(path.resolve())
|
|
142
|
+
|
|
143
|
+
if not path.exists():
|
|
144
|
+
return ValidationResult(
|
|
145
|
+
result=False,
|
|
146
|
+
message="Notebook file does not exist.",
|
|
147
|
+
error_code=1,
|
|
148
|
+
)
|
|
149
|
+
if path.suffix != ".ipynb":
|
|
150
|
+
return ValidationResult(
|
|
151
|
+
result=False,
|
|
152
|
+
message="File must be a Jupyter notebook (.ipynb file). Use Edit for other file types.",
|
|
153
|
+
error_code=2,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
mode = (input_data.edit_mode or "replace").lower()
|
|
157
|
+
if mode not in {"replace", "insert", "delete"}:
|
|
158
|
+
return ValidationResult(
|
|
159
|
+
result=False,
|
|
160
|
+
message="edit_mode must be replace, insert, or delete.",
|
|
161
|
+
error_code=3,
|
|
162
|
+
)
|
|
163
|
+
if mode == "insert" and not input_data.cell_type:
|
|
164
|
+
return ValidationResult(
|
|
165
|
+
result=False,
|
|
166
|
+
message="cell_type is required when using edit_mode=insert.",
|
|
167
|
+
error_code=4,
|
|
168
|
+
)
|
|
169
|
+
if mode != "insert" and not input_data.cell_id:
|
|
170
|
+
return ValidationResult(
|
|
171
|
+
result=False,
|
|
172
|
+
message="cell_id must be specified when using edit_mode=replace or delete.",
|
|
173
|
+
error_code=5,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Check if file has been read before editing
|
|
177
|
+
file_state_cache = getattr(context, "file_state_cache", {}) if context else {}
|
|
178
|
+
file_snapshot = file_state_cache.get(resolved_path)
|
|
179
|
+
|
|
180
|
+
if not file_snapshot:
|
|
181
|
+
return ValidationResult(
|
|
182
|
+
result=False,
|
|
183
|
+
message="Notebook has not been read yet. Read it first before editing.",
|
|
184
|
+
error_code=6,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Check if file has been modified since it was read
|
|
188
|
+
try:
|
|
189
|
+
current_mtime = os.path.getmtime(resolved_path)
|
|
190
|
+
if current_mtime > file_snapshot.timestamp:
|
|
191
|
+
return ValidationResult(
|
|
192
|
+
result=False,
|
|
193
|
+
message="Notebook has been modified since read, either by the user or by a linter. "
|
|
194
|
+
"Read it again before attempting to edit it.",
|
|
195
|
+
error_code=7,
|
|
196
|
+
)
|
|
197
|
+
except OSError:
|
|
198
|
+
pass # File mtime check failed, proceed anyway
|
|
199
|
+
|
|
200
|
+
# Validate notebook structure and target cell.
|
|
201
|
+
try:
|
|
202
|
+
raw = path.read_text(encoding="utf-8")
|
|
203
|
+
nb_json = json.loads(raw)
|
|
204
|
+
except (OSError, json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
205
|
+
logger.warning(
|
|
206
|
+
"Failed to parse notebook: %s: %s",
|
|
207
|
+
type(exc).__name__, exc,
|
|
208
|
+
extra={"path": str(path)},
|
|
209
|
+
)
|
|
210
|
+
return ValidationResult(
|
|
211
|
+
result=False,
|
|
212
|
+
message="Notebook is not valid JSON.",
|
|
213
|
+
error_code=8,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
cells = nb_json.get("cells", [])
|
|
217
|
+
target_index, _ = self._resolve_cell_index(cells, input_data.cell_id, mode)
|
|
218
|
+
if target_index is None:
|
|
219
|
+
if mode == "insert" and input_data.cell_id is None:
|
|
220
|
+
return ValidationResult(result=True)
|
|
221
|
+
return ValidationResult(
|
|
222
|
+
result=False,
|
|
223
|
+
message=f"Cell '{input_data.cell_id}' not found in notebook.",
|
|
224
|
+
error_code=9,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
return ValidationResult(result=True)
|
|
228
|
+
|
|
229
|
+
def render_result_for_assistant(self, output: NotebookEditOutput) -> str:
|
|
230
|
+
if output.error:
|
|
231
|
+
return output.error
|
|
232
|
+
action = output.edit_mode or "replace"
|
|
233
|
+
cell_label = output.cell_id or "(new cell)"
|
|
234
|
+
if action == "delete":
|
|
235
|
+
return f"Deleted cell {cell_label}"
|
|
236
|
+
if action == "insert":
|
|
237
|
+
return f"Inserted cell {cell_label}"
|
|
238
|
+
return f"Updated cell {cell_label}"
|
|
239
|
+
|
|
240
|
+
def render_tool_use_message(self, input_data: NotebookEditInput, verbose: bool = False) -> str:
|
|
241
|
+
parts = [f"path: {input_data.notebook_path}"]
|
|
242
|
+
if input_data.cell_id:
|
|
243
|
+
parts.append(f"cell_id: {input_data.cell_id}")
|
|
244
|
+
if verbose:
|
|
245
|
+
parts.append(f"mode: {input_data.edit_mode or 'replace'}")
|
|
246
|
+
return ", ".join(parts)
|
|
247
|
+
|
|
248
|
+
async def call(
|
|
249
|
+
self, input_data: NotebookEditInput, context: ToolUseContext
|
|
250
|
+
) -> AsyncGenerator[ToolOutput, None]:
|
|
251
|
+
path = _resolve_path(input_data.notebook_path)
|
|
252
|
+
mode = (input_data.edit_mode or "replace").lower()
|
|
253
|
+
cell_type = (input_data.cell_type or "").lower() or None
|
|
254
|
+
new_source = input_data.new_source
|
|
255
|
+
cell_id = input_data.cell_id
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
raw = path.read_text(encoding="utf-8")
|
|
259
|
+
nb_json = json.loads(raw)
|
|
260
|
+
cells = nb_json.get("cells", [])
|
|
261
|
+
|
|
262
|
+
target_index, matched_id = self._resolve_cell_index(cells, cell_id, mode)
|
|
263
|
+
|
|
264
|
+
final_mode = mode
|
|
265
|
+
if final_mode == "replace" and target_index is not None and target_index == len(cells):
|
|
266
|
+
final_mode = "insert"
|
|
267
|
+
if not cell_type:
|
|
268
|
+
cell_type = "code"
|
|
269
|
+
|
|
270
|
+
if final_mode == "delete":
|
|
271
|
+
if target_index is None:
|
|
272
|
+
raise ValueError("Target cell not found for delete.")
|
|
273
|
+
cells.pop(target_index)
|
|
274
|
+
elif final_mode == "insert":
|
|
275
|
+
insert_at = target_index if target_index is not None else 0
|
|
276
|
+
new_id = matched_id or _generate_cell_id()
|
|
277
|
+
new_cell_type = cell_type or "code"
|
|
278
|
+
new_cell = (
|
|
279
|
+
{
|
|
280
|
+
"cell_type": "markdown",
|
|
281
|
+
"id": new_id,
|
|
282
|
+
"source": new_source,
|
|
283
|
+
"metadata": {},
|
|
284
|
+
}
|
|
285
|
+
if new_cell_type == "markdown"
|
|
286
|
+
else {
|
|
287
|
+
"cell_type": "code",
|
|
288
|
+
"id": new_id,
|
|
289
|
+
"source": new_source,
|
|
290
|
+
"metadata": {},
|
|
291
|
+
"execution_count": None, # type: ignore[dict-item]
|
|
292
|
+
"outputs": [],
|
|
293
|
+
}
|
|
294
|
+
)
|
|
295
|
+
cells.insert(insert_at, new_cell)
|
|
296
|
+
matched_id = new_id
|
|
297
|
+
cell_type = new_cell_type
|
|
298
|
+
else: # replace
|
|
299
|
+
if target_index is None:
|
|
300
|
+
raise ValueError("Target cell not found for replace.")
|
|
301
|
+
target_cell = cells[target_index]
|
|
302
|
+
target_cell["source"] = new_source
|
|
303
|
+
if target_cell.get("cell_type") == "code":
|
|
304
|
+
target_cell["execution_count"] = None
|
|
305
|
+
target_cell["outputs"] = []
|
|
306
|
+
if cell_type and cell_type != target_cell.get("cell_type"):
|
|
307
|
+
target_cell["cell_type"] = cell_type
|
|
308
|
+
matched_id = target_cell.get("id") or matched_id
|
|
309
|
+
cell_type = target_cell.get("cell_type", cell_type or "code")
|
|
310
|
+
|
|
311
|
+
nb_json["cells"] = cells
|
|
312
|
+
notebook_language = (
|
|
313
|
+
nb_json.get("metadata", {}).get("language_info", {}).get("name", "python")
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
path.write_text(json.dumps(nb_json, indent=1), encoding="utf-8")
|
|
317
|
+
# Use resolved absolute path to ensure consistency with validation lookup
|
|
318
|
+
abs_notebook_path = str(path.resolve())
|
|
319
|
+
try:
|
|
320
|
+
record_snapshot(
|
|
321
|
+
abs_notebook_path,
|
|
322
|
+
json.dumps(nb_json, indent=1),
|
|
323
|
+
getattr(context, "file_state_cache", {}),
|
|
324
|
+
)
|
|
325
|
+
except (OSError, IOError, RuntimeError) as exc:
|
|
326
|
+
logger.warning(
|
|
327
|
+
"[notebook_edit_tool] Failed to record file snapshot: %s: %s",
|
|
328
|
+
type(exc).__name__, exc,
|
|
329
|
+
extra={"file_path": abs_notebook_path},
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
output = NotebookEditOutput(
|
|
333
|
+
new_source=new_source,
|
|
334
|
+
cell_type=cell_type or "code",
|
|
335
|
+
language=notebook_language,
|
|
336
|
+
edit_mode=final_mode,
|
|
337
|
+
cell_id=matched_id,
|
|
338
|
+
error=None,
|
|
339
|
+
)
|
|
340
|
+
yield ToolResult(
|
|
341
|
+
data=output, result_for_assistant=self.render_result_for_assistant(output)
|
|
342
|
+
)
|
|
343
|
+
except (OSError, json.JSONDecodeError, ValueError, KeyError) as exc:
|
|
344
|
+
# pragma: no cover - error path
|
|
345
|
+
logger.warning(
|
|
346
|
+
"Error editing notebook: %s: %s",
|
|
347
|
+
type(exc).__name__, exc,
|
|
348
|
+
extra={"path": input_data.notebook_path},
|
|
349
|
+
)
|
|
350
|
+
output = NotebookEditOutput(
|
|
351
|
+
new_source=new_source,
|
|
352
|
+
cell_type=cell_type or "code",
|
|
353
|
+
language="python",
|
|
354
|
+
edit_mode=mode,
|
|
355
|
+
cell_id=cell_id,
|
|
356
|
+
error=str(exc),
|
|
357
|
+
)
|
|
358
|
+
yield ToolResult(
|
|
359
|
+
data=output, result_for_assistant=self.render_result_for_assistant(output)
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
def _resolve_cell_index(
|
|
363
|
+
self, cells: list, cell_id: Optional[str], mode: str
|
|
364
|
+
) -> tuple[Optional[int], Optional[str]]:
|
|
365
|
+
"""Return target index and resolved id."""
|
|
366
|
+
if cell_id is None:
|
|
367
|
+
return (0 if mode == "insert" else None, None)
|
|
368
|
+
|
|
369
|
+
# Try numeric index first.
|
|
370
|
+
try:
|
|
371
|
+
idx = int(cell_id)
|
|
372
|
+
if mode == "insert":
|
|
373
|
+
if idx < 0 or idx > len(cells):
|
|
374
|
+
return None, None
|
|
375
|
+
return min(idx + 1, len(cells)), None
|
|
376
|
+
else:
|
|
377
|
+
if idx < 0 or idx >= len(cells):
|
|
378
|
+
return None, None
|
|
379
|
+
return idx, None
|
|
380
|
+
except (ValueError, TypeError):
|
|
381
|
+
pass
|
|
382
|
+
|
|
383
|
+
for i, cell in enumerate(cells):
|
|
384
|
+
if cell.get("id") == cell_id:
|
|
385
|
+
return (i if mode != "insert" else i + 1, cell.get("id"))
|
|
386
|
+
return None, None
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Skill loader tool.
|
|
2
|
+
|
|
3
|
+
Loads SKILL.md content from .ripperdoc/skills or ~/.ripperdoc/skills so the
|
|
4
|
+
assistant can pull in specialized instructions only when needed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import AsyncGenerator, List, Optional
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field
|
|
13
|
+
|
|
14
|
+
from ripperdoc.core.skills import SkillDefinition, find_skill
|
|
15
|
+
from ripperdoc.core.tool import (
|
|
16
|
+
Tool,
|
|
17
|
+
ToolOutput,
|
|
18
|
+
ToolResult,
|
|
19
|
+
ToolUseContext,
|
|
20
|
+
ToolUseExample,
|
|
21
|
+
ValidationResult,
|
|
22
|
+
)
|
|
23
|
+
from ripperdoc.utils.log import get_logger
|
|
24
|
+
|
|
25
|
+
logger = get_logger()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SkillToolInput(BaseModel):
|
|
29
|
+
"""Input schema for the Skill tool."""
|
|
30
|
+
|
|
31
|
+
skill: str = Field(description='The skill name (e.g. "pdf-processing").')
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SkillToolOutput(BaseModel):
|
|
35
|
+
"""Structured output for a loaded skill."""
|
|
36
|
+
|
|
37
|
+
success: bool = True
|
|
38
|
+
skill: str
|
|
39
|
+
description: str
|
|
40
|
+
location: str
|
|
41
|
+
base_dir: str
|
|
42
|
+
path: str
|
|
43
|
+
allowed_tools: List[str] = Field(default_factory=list)
|
|
44
|
+
model: Optional[str] = None
|
|
45
|
+
max_thinking_tokens: Optional[int] = None
|
|
46
|
+
skill_type: str = "prompt"
|
|
47
|
+
disable_model_invocation: bool = False
|
|
48
|
+
content: str
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class SkillTool(Tool[SkillToolInput, SkillToolOutput]):
|
|
52
|
+
"""Load a skill's instructions by name."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, project_path: Optional[Path] = None, home: Optional[Path] = None) -> None:
|
|
55
|
+
self._project_path = project_path
|
|
56
|
+
self._home = home
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def name(self) -> str:
|
|
60
|
+
return "Skill"
|
|
61
|
+
|
|
62
|
+
async def description(self) -> str:
|
|
63
|
+
return (
|
|
64
|
+
"Execute a skill by name to load its SKILL.md instructions. "
|
|
65
|
+
"Use this only when the skill description clearly matches the user's request. "
|
|
66
|
+
"Skill metadata may include allowed-tools, model, or max-thinking-tokens hints."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def input_schema(self) -> type[SkillToolInput]:
|
|
71
|
+
return SkillToolInput
|
|
72
|
+
|
|
73
|
+
def input_examples(self) -> List[ToolUseExample]:
|
|
74
|
+
return [
|
|
75
|
+
ToolUseExample(
|
|
76
|
+
description="Load PDF processing guidance",
|
|
77
|
+
example={"skill": "pdf-processing"},
|
|
78
|
+
),
|
|
79
|
+
ToolUseExample(
|
|
80
|
+
description="Load commit message helper instructions",
|
|
81
|
+
example={"skill": "generating-commit-messages"},
|
|
82
|
+
),
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
async def prompt(self, safe_mode: bool = False) -> str: # noqa: ARG002
|
|
86
|
+
return (
|
|
87
|
+
"Load a skill by name to read its SKILL.md content. "
|
|
88
|
+
"Only call this when the skill description is clearly relevant. "
|
|
89
|
+
"If the skill specifies allowed-tools, model, or max-thinking-tokens in frontmatter, "
|
|
90
|
+
"assume those hints apply for subsequent reasoning. "
|
|
91
|
+
"Skill files may reference additional files under the same directory; "
|
|
92
|
+
"use file tools to read them if needed."
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def is_read_only(self) -> bool:
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
def is_concurrency_safe(self) -> bool:
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
def needs_permissions(self, input_data: Optional[SkillToolInput] = None) -> bool: # noqa: ARG002
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
async def validate_input(
|
|
105
|
+
self,
|
|
106
|
+
input_data: SkillToolInput,
|
|
107
|
+
context: Optional[ToolUseContext] = None, # noqa: ARG002
|
|
108
|
+
) -> ValidationResult:
|
|
109
|
+
skill_name = (input_data.skill or "").strip().lstrip("/")
|
|
110
|
+
if not skill_name:
|
|
111
|
+
return ValidationResult(
|
|
112
|
+
result=False, message="Provide a skill name to load.", error_code=1
|
|
113
|
+
)
|
|
114
|
+
skill = find_skill(skill_name, project_path=self._project_path, home=self._home)
|
|
115
|
+
if not skill:
|
|
116
|
+
return ValidationResult(
|
|
117
|
+
result=False, message=f"Unknown skill: {skill_name}", error_code=2
|
|
118
|
+
)
|
|
119
|
+
if skill.disable_model_invocation:
|
|
120
|
+
return ValidationResult(
|
|
121
|
+
result=False,
|
|
122
|
+
message=f"Skill {skill_name} is blocked by disable-model-invocation.",
|
|
123
|
+
error_code=4,
|
|
124
|
+
)
|
|
125
|
+
if skill.skill_type and skill.skill_type != "prompt":
|
|
126
|
+
return ValidationResult(
|
|
127
|
+
result=False,
|
|
128
|
+
message=f"Skill {skill_name} is not a prompt-based skill (type={skill.skill_type}).",
|
|
129
|
+
error_code=5,
|
|
130
|
+
meta={"skill_type": skill.skill_type},
|
|
131
|
+
)
|
|
132
|
+
return ValidationResult(result=True)
|
|
133
|
+
|
|
134
|
+
def _render_result(self, skill: SkillDefinition) -> str:
|
|
135
|
+
allowed = ", ".join(skill.allowed_tools) if skill.allowed_tools else "no specific limit"
|
|
136
|
+
model_hint = f"\nModel hint: {skill.model}" if skill.model else ""
|
|
137
|
+
max_tokens = (
|
|
138
|
+
f"\nMax thinking tokens hint: {skill.max_thinking_tokens}"
|
|
139
|
+
if skill.max_thinking_tokens is not None
|
|
140
|
+
else ""
|
|
141
|
+
)
|
|
142
|
+
lines = [
|
|
143
|
+
f"Skill loaded: {skill.name} ({skill.location.value})",
|
|
144
|
+
f"Description: {skill.description}",
|
|
145
|
+
f"Skill directory: {skill.base_dir}",
|
|
146
|
+
f"Allowed tools (if specified): {allowed}{model_hint}{max_tokens}",
|
|
147
|
+
"SKILL.md content:",
|
|
148
|
+
skill.content,
|
|
149
|
+
]
|
|
150
|
+
return "\n".join(lines)
|
|
151
|
+
|
|
152
|
+
def _to_output(self, skill: SkillDefinition) -> SkillToolOutput:
|
|
153
|
+
return SkillToolOutput(
|
|
154
|
+
success=True,
|
|
155
|
+
skill=skill.name,
|
|
156
|
+
description=skill.description,
|
|
157
|
+
location=skill.location.value,
|
|
158
|
+
base_dir=str(skill.base_dir),
|
|
159
|
+
path=str(skill.path),
|
|
160
|
+
allowed_tools=list(skill.allowed_tools),
|
|
161
|
+
model=skill.model,
|
|
162
|
+
max_thinking_tokens=skill.max_thinking_tokens,
|
|
163
|
+
skill_type=skill.skill_type,
|
|
164
|
+
disable_model_invocation=skill.disable_model_invocation,
|
|
165
|
+
content=skill.content,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
async def call(
|
|
169
|
+
self, input_data: SkillToolInput, context: ToolUseContext
|
|
170
|
+
) -> AsyncGenerator[ToolOutput, None]: # noqa: ARG002
|
|
171
|
+
skill_name = (input_data.skill or "").strip().lstrip("/")
|
|
172
|
+
skill = find_skill(skill_name, project_path=self._project_path, home=self._home)
|
|
173
|
+
if not skill:
|
|
174
|
+
error_text = (
|
|
175
|
+
f"Skill '{skill_name}' not found. Ensure it exists under "
|
|
176
|
+
"~/.ripperdoc/skills or ./.ripperdoc/skills."
|
|
177
|
+
)
|
|
178
|
+
yield ToolResult(data={"error": error_text}, result_for_assistant=error_text)
|
|
179
|
+
return
|
|
180
|
+
if skill.allowed_tools and context.tool_registry is not None:
|
|
181
|
+
# Ensure preferred tools for this skill are activated in the registry.
|
|
182
|
+
context.tool_registry.activate_tools(skill.allowed_tools)
|
|
183
|
+
|
|
184
|
+
output = self._to_output(skill)
|
|
185
|
+
yield ToolResult(data=output, result_for_assistant=self._render_result(skill))
|
|
186
|
+
|
|
187
|
+
def render_result_for_assistant(self, output: SkillToolOutput) -> str:
|
|
188
|
+
allowed = ", ".join(output.allowed_tools) if output.allowed_tools else "no specific limit"
|
|
189
|
+
model_hint = f"\nModel hint: {output.model}" if output.model else ""
|
|
190
|
+
max_tokens = (
|
|
191
|
+
f"\nMax thinking tokens hint: {output.max_thinking_tokens}"
|
|
192
|
+
if output.max_thinking_tokens is not None
|
|
193
|
+
else ""
|
|
194
|
+
)
|
|
195
|
+
return (
|
|
196
|
+
f"Skill loaded: {output.skill} ({output.location})\n"
|
|
197
|
+
f"Description: {output.description}\n"
|
|
198
|
+
f"Skill directory: {output.base_dir}\n"
|
|
199
|
+
f"Allowed tools (if specified): {allowed}{model_hint}{max_tokens}\n"
|
|
200
|
+
"SKILL.md content:\n"
|
|
201
|
+
f"{output.content}"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
def render_tool_use_message(self, input_data: SkillToolInput, verbose: bool = False) -> str: # noqa: ARG002
|
|
205
|
+
return f"Load skill '{input_data.skill}'"
|