ripperdoc 0.2.2__py3-none-any.whl → 0.2.3__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 +1 -1
- ripperdoc/cli/cli.py +9 -2
- ripperdoc/cli/commands/agents_cmd.py +8 -4
- ripperdoc/cli/commands/cost_cmd.py +5 -0
- ripperdoc/cli/commands/doctor_cmd.py +12 -4
- ripperdoc/cli/commands/memory_cmd.py +6 -13
- ripperdoc/cli/commands/models_cmd.py +36 -6
- ripperdoc/cli/commands/resume_cmd.py +4 -2
- ripperdoc/cli/commands/status_cmd.py +1 -1
- ripperdoc/cli/ui/rich_ui.py +102 -2
- ripperdoc/cli/ui/thinking_spinner.py +128 -0
- ripperdoc/core/agents.py +13 -5
- ripperdoc/core/config.py +9 -1
- ripperdoc/core/providers/__init__.py +31 -0
- ripperdoc/core/providers/anthropic.py +136 -0
- ripperdoc/core/providers/base.py +187 -0
- ripperdoc/core/providers/gemini.py +172 -0
- ripperdoc/core/providers/openai.py +142 -0
- ripperdoc/core/query.py +331 -141
- ripperdoc/core/query_utils.py +64 -23
- ripperdoc/core/tool.py +5 -3
- ripperdoc/sdk/client.py +12 -1
- ripperdoc/tools/background_shell.py +54 -18
- ripperdoc/tools/bash_tool.py +33 -13
- ripperdoc/tools/file_edit_tool.py +13 -0
- ripperdoc/tools/file_read_tool.py +16 -0
- ripperdoc/tools/file_write_tool.py +13 -0
- ripperdoc/tools/glob_tool.py +5 -1
- ripperdoc/tools/ls_tool.py +14 -10
- ripperdoc/tools/multi_edit_tool.py +12 -0
- ripperdoc/tools/notebook_edit_tool.py +12 -0
- ripperdoc/tools/todo_tool.py +1 -3
- ripperdoc/tools/tool_search_tool.py +8 -4
- ripperdoc/utils/file_watch.py +134 -0
- ripperdoc/utils/git_utils.py +36 -38
- ripperdoc/utils/json_utils.py +1 -2
- ripperdoc/utils/log.py +3 -4
- ripperdoc/utils/memory.py +1 -3
- ripperdoc/utils/message_compaction.py +2 -6
- ripperdoc/utils/messages.py +9 -13
- ripperdoc/utils/output_utils.py +1 -3
- ripperdoc/utils/prompt.py +17 -0
- ripperdoc/utils/session_usage.py +7 -0
- ripperdoc/utils/shell_utils.py +159 -0
- {ripperdoc-0.2.2.dist-info → ripperdoc-0.2.3.dist-info}/METADATA +1 -1
- ripperdoc-0.2.3.dist-info/RECORD +95 -0
- ripperdoc-0.2.2.dist-info/RECORD +0 -86
- {ripperdoc-0.2.2.dist-info → ripperdoc-0.2.3.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.2.dist-info → ripperdoc-0.2.3.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.2.dist-info → ripperdoc-0.2.3.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.2.dist-info → ripperdoc-0.2.3.dist-info}/top_level.txt +0 -0
ripperdoc/core/query_utils.py
CHANGED
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import re
|
|
7
|
-
from typing import Any, Dict, List, Optional, Union
|
|
7
|
+
from typing import Any, Dict, List, Mapping, Optional, Union
|
|
8
8
|
from uuid import uuid4
|
|
9
9
|
|
|
10
10
|
from json_repair import repair_json
|
|
@@ -26,17 +26,25 @@ from ripperdoc.utils.messages import (
|
|
|
26
26
|
logger = get_logger()
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
def _safe_int(value:
|
|
29
|
+
def _safe_int(value: object) -> int:
|
|
30
30
|
"""Best-effort int conversion for usage counters."""
|
|
31
31
|
try:
|
|
32
32
|
if value is None:
|
|
33
33
|
return 0
|
|
34
|
-
|
|
34
|
+
if isinstance(value, bool):
|
|
35
|
+
return int(value)
|
|
36
|
+
if isinstance(value, (int, float)):
|
|
37
|
+
return int(value)
|
|
38
|
+
if isinstance(value, str):
|
|
39
|
+
return int(value)
|
|
40
|
+
if hasattr(value, "__int__"):
|
|
41
|
+
return int(value) # type: ignore[arg-type]
|
|
42
|
+
return 0
|
|
35
43
|
except (TypeError, ValueError):
|
|
36
44
|
return 0
|
|
37
45
|
|
|
38
46
|
|
|
39
|
-
def _get_usage_field(usage: Any, field: str) -> int:
|
|
47
|
+
def _get_usage_field(usage: Optional[Mapping[str, Any] | object], field: str) -> int:
|
|
40
48
|
"""Fetch a usage field from either a dict or object."""
|
|
41
49
|
if usage is None:
|
|
42
50
|
return 0
|
|
@@ -45,7 +53,7 @@ def _get_usage_field(usage: Any, field: str) -> int:
|
|
|
45
53
|
return _safe_int(getattr(usage, field, 0))
|
|
46
54
|
|
|
47
55
|
|
|
48
|
-
def anthropic_usage_tokens(usage: Any) -> Dict[str, int]:
|
|
56
|
+
def anthropic_usage_tokens(usage: Optional[Mapping[str, Any] | object]) -> Dict[str, int]:
|
|
49
57
|
"""Extract token counts from an Anthropic response usage payload."""
|
|
50
58
|
return {
|
|
51
59
|
"input_tokens": _get_usage_field(usage, "input_tokens"),
|
|
@@ -55,7 +63,7 @@ def anthropic_usage_tokens(usage: Any) -> Dict[str, int]:
|
|
|
55
63
|
}
|
|
56
64
|
|
|
57
65
|
|
|
58
|
-
def openai_usage_tokens(usage: Any) -> Dict[str, int]:
|
|
66
|
+
def openai_usage_tokens(usage: Optional[Mapping[str, Any] | object]) -> Dict[str, int]:
|
|
59
67
|
"""Extract token counts from an OpenAI-compatible response usage payload."""
|
|
60
68
|
prompt_details = None
|
|
61
69
|
if isinstance(usage, dict):
|
|
@@ -73,8 +81,24 @@ def openai_usage_tokens(usage: Any) -> Dict[str, int]:
|
|
|
73
81
|
}
|
|
74
82
|
|
|
75
83
|
|
|
84
|
+
def estimate_cost_usd(model_profile: ModelProfile, usage_tokens: Dict[str, int]) -> float:
|
|
85
|
+
"""Compute USD cost using per-1M token pricing from the model profile."""
|
|
86
|
+
input_price = getattr(model_profile, "input_cost_per_million_tokens", 0.0) or 0.0
|
|
87
|
+
output_price = getattr(model_profile, "output_cost_per_million_tokens", 0.0) or 0.0
|
|
88
|
+
|
|
89
|
+
total_input_tokens = (
|
|
90
|
+
_safe_int(usage_tokens.get("input_tokens"))
|
|
91
|
+
+ _safe_int(usage_tokens.get("cache_read_input_tokens"))
|
|
92
|
+
+ _safe_int(usage_tokens.get("cache_creation_input_tokens"))
|
|
93
|
+
)
|
|
94
|
+
output_tokens = _safe_int(usage_tokens.get("output_tokens"))
|
|
95
|
+
|
|
96
|
+
cost = (total_input_tokens * input_price + output_tokens * output_price) / 1_000_000
|
|
97
|
+
return float(cost)
|
|
98
|
+
|
|
99
|
+
|
|
76
100
|
def resolve_model_profile(model: str) -> ModelProfile:
|
|
77
|
-
"""Resolve a model pointer to a concrete profile
|
|
101
|
+
"""Resolve a model pointer to a concrete profile, falling back to a safe default."""
|
|
78
102
|
config = get_global_config()
|
|
79
103
|
profile_name = getattr(config.model_pointers, model, None) or model
|
|
80
104
|
model_profile = config.model_profiles.get(profile_name)
|
|
@@ -82,7 +106,11 @@ def resolve_model_profile(model: str) -> ModelProfile:
|
|
|
82
106
|
fallback_profile = getattr(config.model_pointers, "main", "default")
|
|
83
107
|
model_profile = config.model_profiles.get(fallback_profile)
|
|
84
108
|
if not model_profile:
|
|
85
|
-
|
|
109
|
+
logger.warning(
|
|
110
|
+
"[config] No model profile found; using built-in default profile",
|
|
111
|
+
extra={"model_pointer": model},
|
|
112
|
+
)
|
|
113
|
+
return ModelProfile(provider=ProviderType.OPENAI_COMPATIBLE, model="gpt-4o-mini")
|
|
86
114
|
return model_profile
|
|
87
115
|
|
|
88
116
|
|
|
@@ -103,12 +131,10 @@ def _parse_text_mode_json_blocks(text: str) -> Optional[List[Dict[str, Any]]]:
|
|
|
103
131
|
if not text or not isinstance(text, str):
|
|
104
132
|
return None
|
|
105
133
|
|
|
106
|
-
code_blocks = re.findall(
|
|
107
|
-
r"```(?:\s*json)?\s*([\s\S]*?)\s*```", text, flags=re.IGNORECASE
|
|
108
|
-
)
|
|
134
|
+
code_blocks = re.findall(r"```(?:\s*json)?\s*([\s\S]*?)\s*```", text, flags=re.IGNORECASE)
|
|
109
135
|
candidates = [blk.strip() for blk in code_blocks if blk.strip()]
|
|
110
136
|
|
|
111
|
-
def _normalize_blocks(parsed:
|
|
137
|
+
def _normalize_blocks(parsed: object) -> Optional[List[Dict[str, Any]]]:
|
|
112
138
|
raw_blocks = parsed if isinstance(parsed, list) else [parsed]
|
|
113
139
|
normalized: List[Dict[str, Any]] = []
|
|
114
140
|
for raw in raw_blocks:
|
|
@@ -204,7 +230,9 @@ def _tool_prompt_for_text_mode(tools: List[Tool[Any, Any]]) -> str:
|
|
|
204
230
|
|
|
205
231
|
schema_json = ""
|
|
206
232
|
try:
|
|
207
|
-
schema_json = json.dumps(
|
|
233
|
+
schema_json = json.dumps(
|
|
234
|
+
tool.input_schema.model_json_schema(), ensure_ascii=False, indent=2
|
|
235
|
+
)
|
|
208
236
|
except (AttributeError, TypeError, ValueError) as exc:
|
|
209
237
|
logger.debug(
|
|
210
238
|
"[tool_prompt] Failed to render input_schema",
|
|
@@ -218,7 +246,12 @@ def _tool_prompt_for_text_mode(tools: List[Tool[Any, Any]]) -> str:
|
|
|
218
246
|
|
|
219
247
|
example_blocks = [
|
|
220
248
|
{"type": "text", "text": "好的,我来帮你查看一下README.md文件"},
|
|
221
|
-
{
|
|
249
|
+
{
|
|
250
|
+
"type": "tool_use",
|
|
251
|
+
"tool_use_id": "tool_id_000001",
|
|
252
|
+
"tool": "View",
|
|
253
|
+
"input": {"file_path": "README.md"},
|
|
254
|
+
},
|
|
222
255
|
]
|
|
223
256
|
lines.append("Example:")
|
|
224
257
|
lines.append("```json")
|
|
@@ -228,7 +261,9 @@ def _tool_prompt_for_text_mode(tools: List[Tool[Any, Any]]) -> str:
|
|
|
228
261
|
return "\n".join(lines)
|
|
229
262
|
|
|
230
263
|
|
|
231
|
-
def text_mode_history(
|
|
264
|
+
def text_mode_history(
|
|
265
|
+
messages: List[Union[UserMessage, AssistantMessage, ProgressMessage]],
|
|
266
|
+
) -> List[Union[UserMessage, AssistantMessage]]:
|
|
232
267
|
"""Convert a message history into text-only form for text mode."""
|
|
233
268
|
|
|
234
269
|
def _normalize_block(block: Any) -> Optional[Dict[str, Any]]:
|
|
@@ -269,9 +304,13 @@ def text_mode_history(messages: List[Union[UserMessage, AssistantMessage, Progre
|
|
|
269
304
|
if isinstance(content, list):
|
|
270
305
|
normalized_blocks = []
|
|
271
306
|
for block in content:
|
|
272
|
-
block_type = getattr(block, "type", None) or (
|
|
273
|
-
|
|
274
|
-
|
|
307
|
+
block_type = getattr(block, "type", None) or (
|
|
308
|
+
block.get("type") if isinstance(block, dict) else None
|
|
309
|
+
)
|
|
310
|
+
block_text = (
|
|
311
|
+
getattr(block, "text", None)
|
|
312
|
+
if hasattr(block, "text")
|
|
313
|
+
else (block.get("text") if isinstance(block, dict) else None)
|
|
275
314
|
)
|
|
276
315
|
if block_type == "text" and isinstance(block_text, str):
|
|
277
316
|
parsed_nested = _parse_text_mode_json_blocks(block_text)
|
|
@@ -287,7 +326,9 @@ def text_mode_history(messages: List[Union[UserMessage, AssistantMessage, Progre
|
|
|
287
326
|
elif isinstance(content, str):
|
|
288
327
|
parsed_blocks = _parse_text_mode_json_blocks(content)
|
|
289
328
|
if parsed_blocks:
|
|
290
|
-
text_content =
|
|
329
|
+
text_content = (
|
|
330
|
+
f"```json\n{json.dumps(parsed_blocks, ensure_ascii=False, indent=2)}\n```"
|
|
331
|
+
)
|
|
291
332
|
else:
|
|
292
333
|
text_content = content
|
|
293
334
|
else:
|
|
@@ -301,7 +342,9 @@ def text_mode_history(messages: List[Union[UserMessage, AssistantMessage, Progre
|
|
|
301
342
|
return converted
|
|
302
343
|
|
|
303
344
|
|
|
304
|
-
def _maybe_convert_json_block_to_tool_use(
|
|
345
|
+
def _maybe_convert_json_block_to_tool_use(
|
|
346
|
+
content_blocks: List[Dict[str, Any]],
|
|
347
|
+
) -> List[Dict[str, Any]]:
|
|
305
348
|
"""Convert any text blocks containing JSON content to structured content blocks."""
|
|
306
349
|
if not content_blocks:
|
|
307
350
|
return content_blocks
|
|
@@ -437,9 +480,7 @@ async def build_openai_tool_schemas(tools: List[Tool[Any, Any]]) -> List[Dict[st
|
|
|
437
480
|
return openai_tools
|
|
438
481
|
|
|
439
482
|
|
|
440
|
-
def content_blocks_from_anthropic_response(
|
|
441
|
-
response: Any, tool_mode: str
|
|
442
|
-
) -> List[Dict[str, Any]]:
|
|
483
|
+
def content_blocks_from_anthropic_response(response: Any, tool_mode: str) -> List[Dict[str, Any]]:
|
|
443
484
|
"""Normalize Anthropic response content to our internal block format."""
|
|
444
485
|
blocks: List[Dict[str, Any]] = []
|
|
445
486
|
for block in getattr(response, "content", []) or []:
|
ripperdoc/core/tool.py
CHANGED
|
@@ -8,6 +8,7 @@ import json
|
|
|
8
8
|
from abc import ABC, abstractmethod
|
|
9
9
|
from typing import Any, AsyncGenerator, Dict, List, Optional, TypeVar, Generic, Union
|
|
10
10
|
from pydantic import BaseModel, ConfigDict, Field
|
|
11
|
+
from ripperdoc.utils.file_watch import FileSnapshot
|
|
11
12
|
from ripperdoc.utils.log import get_logger
|
|
12
13
|
|
|
13
14
|
|
|
@@ -39,7 +40,8 @@ class ToolUseContext(BaseModel):
|
|
|
39
40
|
safe_mode: bool = False
|
|
40
41
|
verbose: bool = False
|
|
41
42
|
permission_checker: Optional[Any] = None
|
|
42
|
-
read_file_timestamps: Dict[str, float] =
|
|
43
|
+
read_file_timestamps: Dict[str, float] = Field(default_factory=dict)
|
|
44
|
+
file_state_cache: Dict[str, "FileSnapshot"] = Field(default_factory=dict)
|
|
43
45
|
tool_registry: Optional[Any] = None
|
|
44
46
|
abort_signal: Optional[Any] = None
|
|
45
47
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
@@ -202,7 +204,7 @@ async def build_tool_description(
|
|
|
202
204
|
except Exception:
|
|
203
205
|
logger.exception(
|
|
204
206
|
"[tool] Failed to build input example section",
|
|
205
|
-
extra={"tool": getattr(tool,
|
|
207
|
+
extra={"tool": getattr(tool, "name", None)},
|
|
206
208
|
)
|
|
207
209
|
return description_text
|
|
208
210
|
|
|
@@ -221,7 +223,7 @@ def tool_input_examples(tool: Tool[Any, Any], limit: int = 5) -> List[Dict[str,
|
|
|
221
223
|
except Exception:
|
|
222
224
|
logger.exception(
|
|
223
225
|
"[tool] Failed to format tool input example",
|
|
224
|
-
extra={"tool": getattr(tool,
|
|
226
|
+
extra={"tool": getattr(tool, "name", None)},
|
|
225
227
|
)
|
|
226
228
|
continue
|
|
227
229
|
return results
|
ripperdoc/sdk/client.py
CHANGED
|
@@ -19,11 +19,13 @@ from typing import (
|
|
|
19
19
|
List,
|
|
20
20
|
Optional,
|
|
21
21
|
Sequence,
|
|
22
|
+
Tuple,
|
|
22
23
|
Union,
|
|
23
24
|
)
|
|
24
25
|
|
|
25
26
|
from ripperdoc.core.default_tools import get_default_tools
|
|
26
27
|
from ripperdoc.core.query import QueryContext, query as _core_query
|
|
28
|
+
from ripperdoc.core.permissions import PermissionResult
|
|
27
29
|
from ripperdoc.core.system_prompt import build_system_prompt
|
|
28
30
|
from ripperdoc.core.tool import Tool
|
|
29
31
|
from ripperdoc.tools.task_tool import TaskTool
|
|
@@ -42,7 +44,16 @@ from ripperdoc.utils.mcp import (
|
|
|
42
44
|
)
|
|
43
45
|
|
|
44
46
|
MessageType = Union[UserMessage, AssistantMessage, ProgressMessage]
|
|
45
|
-
PermissionChecker = Callable[
|
|
47
|
+
PermissionChecker = Callable[
|
|
48
|
+
[Tool[Any, Any], Any],
|
|
49
|
+
Union[
|
|
50
|
+
PermissionResult,
|
|
51
|
+
Dict[str, Any],
|
|
52
|
+
Tuple[bool, Optional[str]],
|
|
53
|
+
bool,
|
|
54
|
+
Awaitable[Union[PermissionResult, Dict[str, Any], Tuple[bool, Optional[str]], bool]],
|
|
55
|
+
],
|
|
56
|
+
]
|
|
46
57
|
QueryRunner = Callable[
|
|
47
58
|
[
|
|
48
59
|
List[MessageType],
|
|
@@ -14,6 +14,9 @@ import uuid
|
|
|
14
14
|
from dataclasses import dataclass, field
|
|
15
15
|
from typing import Any, Dict, List, Optional
|
|
16
16
|
|
|
17
|
+
import atexit
|
|
18
|
+
|
|
19
|
+
from ripperdoc.utils.shell_utils import build_shell_command, find_suitable_shell
|
|
17
20
|
from ripperdoc.utils.log import get_logger
|
|
18
21
|
|
|
19
22
|
|
|
@@ -43,6 +46,7 @@ _tasks_lock = threading.Lock()
|
|
|
43
46
|
_background_loop: Optional[asyncio.AbstractEventLoop] = None
|
|
44
47
|
_background_thread: Optional[threading.Thread] = None
|
|
45
48
|
_loop_lock = threading.Lock()
|
|
49
|
+
_shutdown_registered = False
|
|
46
50
|
|
|
47
51
|
|
|
48
52
|
def _ensure_background_loop() -> asyncio.AbstractEventLoop:
|
|
@@ -74,9 +78,18 @@ def _ensure_background_loop() -> asyncio.AbstractEventLoop:
|
|
|
74
78
|
|
|
75
79
|
_background_loop = loop
|
|
76
80
|
_background_thread = thread
|
|
81
|
+
_register_shutdown_hook()
|
|
77
82
|
return loop
|
|
78
83
|
|
|
79
84
|
|
|
85
|
+
def _register_shutdown_hook() -> None:
|
|
86
|
+
global _shutdown_registered
|
|
87
|
+
if _shutdown_registered:
|
|
88
|
+
return
|
|
89
|
+
atexit.register(shutdown_background_shell)
|
|
90
|
+
_shutdown_registered = True
|
|
91
|
+
|
|
92
|
+
|
|
80
93
|
def _submit_to_background_loop(coro: Any) -> concurrent.futures.Future:
|
|
81
94
|
"""Run a coroutine on the background loop and return a thread-safe future."""
|
|
82
95
|
loop = _ensure_background_loop()
|
|
@@ -153,24 +166,15 @@ async def _start_background_command(
|
|
|
153
166
|
command: str, timeout: Optional[float] = None, shell_executable: Optional[str] = None
|
|
154
167
|
) -> str:
|
|
155
168
|
"""Launch a background shell command on the dedicated loop."""
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
)
|
|
166
|
-
else:
|
|
167
|
-
process = await asyncio.create_subprocess_shell(
|
|
168
|
-
command,
|
|
169
|
-
stdout=asyncio.subprocess.PIPE,
|
|
170
|
-
stderr=asyncio.subprocess.PIPE,
|
|
171
|
-
stdin=asyncio.subprocess.DEVNULL,
|
|
172
|
-
start_new_session=False,
|
|
173
|
-
)
|
|
169
|
+
selected_shell = shell_executable or find_suitable_shell()
|
|
170
|
+
argv = build_shell_command(selected_shell, command)
|
|
171
|
+
process = await asyncio.create_subprocess_exec(
|
|
172
|
+
*argv,
|
|
173
|
+
stdout=asyncio.subprocess.PIPE,
|
|
174
|
+
stderr=asyncio.subprocess.PIPE,
|
|
175
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
176
|
+
start_new_session=False,
|
|
177
|
+
)
|
|
174
178
|
|
|
175
179
|
task_id = f"bash_{uuid.uuid4().hex[:8]}"
|
|
176
180
|
record = BackgroundTask(
|
|
@@ -295,3 +299,35 @@ def list_background_tasks() -> List[str]:
|
|
|
295
299
|
"""Return known background task ids."""
|
|
296
300
|
with _tasks_lock:
|
|
297
301
|
return list(_tasks.keys())
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def shutdown_background_shell() -> None:
|
|
305
|
+
"""Stop background tasks/loop to avoid asyncio 'Event loop is closed' warnings."""
|
|
306
|
+
global _background_loop, _background_thread
|
|
307
|
+
|
|
308
|
+
loop = _background_loop
|
|
309
|
+
with _tasks_lock:
|
|
310
|
+
tasks = list(_tasks.values())
|
|
311
|
+
_tasks.clear()
|
|
312
|
+
|
|
313
|
+
for task in tasks:
|
|
314
|
+
try:
|
|
315
|
+
task.killed = True
|
|
316
|
+
task.process.kill()
|
|
317
|
+
except Exception:
|
|
318
|
+
pass
|
|
319
|
+
for reader in task.reader_tasks:
|
|
320
|
+
if loop and loop.is_running():
|
|
321
|
+
loop.call_soon_threadsafe(reader.cancel)
|
|
322
|
+
task.done_event.set()
|
|
323
|
+
|
|
324
|
+
if loop and loop.is_running():
|
|
325
|
+
try:
|
|
326
|
+
loop.call_soon_threadsafe(loop.stop)
|
|
327
|
+
except Exception:
|
|
328
|
+
pass
|
|
329
|
+
if _background_thread and _background_thread.is_alive():
|
|
330
|
+
_background_thread.join(timeout=2)
|
|
331
|
+
|
|
332
|
+
_background_loop = None
|
|
333
|
+
_background_thread = None
|
ripperdoc/tools/bash_tool.py
CHANGED
|
@@ -49,6 +49,7 @@ from ripperdoc.utils.permissions.tool_permission_utils import (
|
|
|
49
49
|
from ripperdoc.utils.permissions import PermissionDecision
|
|
50
50
|
from ripperdoc.utils.sandbox_utils import create_sandbox_wrapper, is_sandbox_available
|
|
51
51
|
from ripperdoc.utils.safe_get_cwd import get_original_cwd, safe_get_cwd
|
|
52
|
+
from ripperdoc.utils.shell_utils import build_shell_command, find_suitable_shell
|
|
52
53
|
from ripperdoc.utils.log import get_logger
|
|
53
54
|
|
|
54
55
|
logger = get_logger()
|
|
@@ -151,6 +152,15 @@ build projects, run tests, and interact with the file system."""
|
|
|
151
152
|
|
|
152
153
|
async def prompt(self, safe_mode: bool = False) -> str:
|
|
153
154
|
sandbox_available = is_sandbox_available()
|
|
155
|
+
try:
|
|
156
|
+
current_shell = find_suitable_shell()
|
|
157
|
+
except Exception as exc: # pragma: no cover - defensive guard
|
|
158
|
+
current_shell = f"Unavailable ({exc})"
|
|
159
|
+
|
|
160
|
+
shell_info = (
|
|
161
|
+
f"Current shell used for execution: {current_shell}\n"
|
|
162
|
+
f"- Override via RIPPERDOC_SHELL or RIPPERDOC_SHELL_PATH env vars, or pass shellExecutable input.\n"
|
|
163
|
+
)
|
|
154
164
|
|
|
155
165
|
read_only_section = ""
|
|
156
166
|
if sandbox_available:
|
|
@@ -235,6 +245,8 @@ build projects, run tests, and interact with the file system."""
|
|
|
235
245
|
f"""\
|
|
236
246
|
Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
|
|
237
247
|
|
|
248
|
+
{shell_info}
|
|
249
|
+
|
|
238
250
|
Before executing the command, please follow these steps:
|
|
239
251
|
|
|
240
252
|
1. Directory Verification:
|
|
@@ -486,6 +498,23 @@ build projects, run tests, and interact with the file system."""
|
|
|
486
498
|
"""Execute the bash command."""
|
|
487
499
|
|
|
488
500
|
effective_command, auto_background = self._detect_auto_background(input_data.command)
|
|
501
|
+
try:
|
|
502
|
+
resolved_shell = input_data.shell_executable or find_suitable_shell()
|
|
503
|
+
except Exception as exc: # pragma: no cover - defensive guard
|
|
504
|
+
error_output = BashToolOutput(
|
|
505
|
+
stdout="",
|
|
506
|
+
stderr=f"Failed to select shell: {exc}",
|
|
507
|
+
exit_code=-1,
|
|
508
|
+
command=effective_command,
|
|
509
|
+
sandbox=bool(input_data.sandbox),
|
|
510
|
+
is_error=True,
|
|
511
|
+
)
|
|
512
|
+
yield ToolResult(
|
|
513
|
+
data=error_output,
|
|
514
|
+
result_for_assistant=self.render_result_for_assistant(error_output),
|
|
515
|
+
)
|
|
516
|
+
return
|
|
517
|
+
|
|
489
518
|
timeout_ms = input_data.timeout or DEFAULT_TIMEOUT_MS
|
|
490
519
|
if MAX_BASH_TIMEOUT_MS:
|
|
491
520
|
timeout_ms = min(timeout_ms, MAX_BASH_TIMEOUT_MS)
|
|
@@ -544,18 +573,9 @@ build projects, run tests, and interact with the file system."""
|
|
|
544
573
|
should_background = False
|
|
545
574
|
|
|
546
575
|
async def _spawn_process() -> asyncio.subprocess.Process:
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
"-c",
|
|
551
|
-
final_command,
|
|
552
|
-
stdout=asyncio.subprocess.PIPE,
|
|
553
|
-
stderr=asyncio.subprocess.PIPE,
|
|
554
|
-
stdin=asyncio.subprocess.DEVNULL,
|
|
555
|
-
start_new_session=False,
|
|
556
|
-
)
|
|
557
|
-
return await asyncio.create_subprocess_shell(
|
|
558
|
-
final_command,
|
|
576
|
+
argv = build_shell_command(resolved_shell, final_command)
|
|
577
|
+
return await asyncio.create_subprocess_exec(
|
|
578
|
+
*argv,
|
|
559
579
|
stdout=asyncio.subprocess.PIPE,
|
|
560
580
|
stderr=asyncio.subprocess.PIPE,
|
|
561
581
|
stdin=asyncio.subprocess.DEVNULL,
|
|
@@ -592,7 +612,7 @@ build projects, run tests, and interact with the file system."""
|
|
|
592
612
|
else (timeout_seconds if timeout_seconds > 0 else None)
|
|
593
613
|
)
|
|
594
614
|
task_id = await start_background_command(
|
|
595
|
-
final_command, timeout=bg_timeout, shell_executable=
|
|
615
|
+
final_command, timeout=bg_timeout, shell_executable=resolved_shell
|
|
596
616
|
)
|
|
597
617
|
|
|
598
618
|
output = BashToolOutput(
|
|
@@ -16,6 +16,7 @@ from ripperdoc.core.tool import (
|
|
|
16
16
|
ValidationResult,
|
|
17
17
|
)
|
|
18
18
|
from ripperdoc.utils.log import get_logger
|
|
19
|
+
from ripperdoc.utils.file_watch import record_snapshot
|
|
19
20
|
|
|
20
21
|
logger = get_logger()
|
|
21
22
|
|
|
@@ -185,6 +186,18 @@ match exactly (including whitespace and indentation)."""
|
|
|
185
186
|
with open(input_data.file_path, "w", encoding="utf-8") as f:
|
|
186
187
|
f.write(new_content)
|
|
187
188
|
|
|
189
|
+
try:
|
|
190
|
+
record_snapshot(
|
|
191
|
+
input_data.file_path,
|
|
192
|
+
new_content,
|
|
193
|
+
getattr(context, "file_state_cache", {}),
|
|
194
|
+
)
|
|
195
|
+
except Exception:
|
|
196
|
+
logger.exception(
|
|
197
|
+
"[file_edit_tool] Failed to record file snapshot",
|
|
198
|
+
extra={"file_path": input_data.file_path},
|
|
199
|
+
)
|
|
200
|
+
|
|
188
201
|
# Generate diff for display
|
|
189
202
|
import difflib
|
|
190
203
|
|
|
@@ -16,6 +16,7 @@ from ripperdoc.core.tool import (
|
|
|
16
16
|
ValidationResult,
|
|
17
17
|
)
|
|
18
18
|
from ripperdoc.utils.log import get_logger
|
|
19
|
+
from ripperdoc.utils.file_watch import record_snapshot
|
|
19
20
|
|
|
20
21
|
logger = get_logger()
|
|
21
22
|
|
|
@@ -143,6 +144,21 @@ and limit to read only a portion of the file."""
|
|
|
143
144
|
|
|
144
145
|
content = "".join(selected_lines)
|
|
145
146
|
|
|
147
|
+
# Remember what we read so we can detect user edits later.
|
|
148
|
+
try:
|
|
149
|
+
record_snapshot(
|
|
150
|
+
input_data.file_path,
|
|
151
|
+
content,
|
|
152
|
+
getattr(context, "file_state_cache", {}),
|
|
153
|
+
offset=offset,
|
|
154
|
+
limit=limit,
|
|
155
|
+
)
|
|
156
|
+
except Exception:
|
|
157
|
+
logger.exception(
|
|
158
|
+
"[file_read_tool] Failed to record file snapshot",
|
|
159
|
+
extra={"file_path": input_data.file_path},
|
|
160
|
+
)
|
|
161
|
+
|
|
146
162
|
output = FileReadToolOutput(
|
|
147
163
|
content=content,
|
|
148
164
|
file_path=input_data.file_path,
|
|
@@ -17,6 +17,7 @@ from ripperdoc.core.tool import (
|
|
|
17
17
|
ValidationResult,
|
|
18
18
|
)
|
|
19
19
|
from ripperdoc.utils.log import get_logger
|
|
20
|
+
from ripperdoc.utils.file_watch import record_snapshot
|
|
20
21
|
|
|
21
22
|
logger = get_logger()
|
|
22
23
|
|
|
@@ -125,6 +126,18 @@ NEVER write new files unless explicitly required by the user."""
|
|
|
125
126
|
|
|
126
127
|
bytes_written = len(input_data.content.encode("utf-8"))
|
|
127
128
|
|
|
129
|
+
try:
|
|
130
|
+
record_snapshot(
|
|
131
|
+
input_data.file_path,
|
|
132
|
+
input_data.content,
|
|
133
|
+
getattr(context, "file_state_cache", {}),
|
|
134
|
+
)
|
|
135
|
+
except Exception:
|
|
136
|
+
logger.exception(
|
|
137
|
+
"[file_write_tool] Failed to record file snapshot",
|
|
138
|
+
extra={"file_path": input_data.file_path},
|
|
139
|
+
)
|
|
140
|
+
|
|
128
141
|
output = FileWriteToolOutput(
|
|
129
142
|
file_path=input_data.file_path,
|
|
130
143
|
bytes_written=bytes_written,
|
ripperdoc/tools/glob_tool.py
CHANGED
|
@@ -112,7 +112,11 @@ class GlobTool(Tool[GlobToolInput, GlobToolOutput]):
|
|
|
112
112
|
rendered_path = ""
|
|
113
113
|
if input_data.path:
|
|
114
114
|
candidate_path = Path(input_data.path)
|
|
115
|
-
absolute_path =
|
|
115
|
+
absolute_path = (
|
|
116
|
+
candidate_path
|
|
117
|
+
if candidate_path.is_absolute()
|
|
118
|
+
else (base_path / candidate_path).resolve()
|
|
119
|
+
)
|
|
116
120
|
|
|
117
121
|
try:
|
|
118
122
|
relative_path = absolute_path.relative_to(base_path)
|
ripperdoc/tools/ls_tool.py
CHANGED
|
@@ -142,22 +142,22 @@ def _should_skip(
|
|
|
142
142
|
path: Path,
|
|
143
143
|
root_path: Path,
|
|
144
144
|
patterns: list[str],
|
|
145
|
-
ignore_map: Optional[Dict[Optional[Path], List[str]]] = None
|
|
145
|
+
ignore_map: Optional[Dict[Optional[Path], List[str]]] = None,
|
|
146
146
|
) -> bool:
|
|
147
147
|
name = path.name
|
|
148
148
|
if name.startswith("."):
|
|
149
149
|
return True
|
|
150
150
|
if "__pycache__" in path.parts:
|
|
151
151
|
return True
|
|
152
|
-
|
|
152
|
+
|
|
153
153
|
# Check against ignore patterns
|
|
154
154
|
if ignore_map and should_ignore_path(path, root_path, ignore_map):
|
|
155
155
|
return True
|
|
156
|
-
|
|
156
|
+
|
|
157
157
|
# Also check against direct patterns for backward compatibility
|
|
158
158
|
if patterns and _matches_ignore(path, root_path, patterns):
|
|
159
159
|
return True
|
|
160
|
-
|
|
160
|
+
|
|
161
161
|
return False
|
|
162
162
|
|
|
163
163
|
|
|
@@ -346,7 +346,9 @@ class LSTool(Tool[LSToolInput, LSToolOutput]):
|
|
|
346
346
|
try:
|
|
347
347
|
root_path = _resolve_directory_path(input_data.path)
|
|
348
348
|
except Exception:
|
|
349
|
-
return ValidationResult(
|
|
349
|
+
return ValidationResult(
|
|
350
|
+
result=False, message=f"Unable to resolve path: {input_data.path}"
|
|
351
|
+
)
|
|
350
352
|
|
|
351
353
|
if not root_path.is_absolute():
|
|
352
354
|
return ValidationResult(result=False, message=f"Path is not absolute: {root_path}")
|
|
@@ -392,7 +394,9 @@ class LSTool(Tool[LSToolInput, LSToolOutput]):
|
|
|
392
394
|
return f'path: "{input_data.path}"{ignore_display}'
|
|
393
395
|
|
|
394
396
|
try:
|
|
395
|
-
relative_path =
|
|
397
|
+
relative_path = (
|
|
398
|
+
_relative_path_for_display(resolved_path, base_path) or resolved_path.as_posix()
|
|
399
|
+
)
|
|
396
400
|
except Exception:
|
|
397
401
|
relative_path = str(resolved_path)
|
|
398
402
|
|
|
@@ -431,18 +435,18 @@ class LSTool(Tool[LSToolInput, LSToolOutput]):
|
|
|
431
435
|
git_root = get_git_root(root_path)
|
|
432
436
|
if git_root:
|
|
433
437
|
git_info["repository"] = str(git_root)
|
|
434
|
-
|
|
438
|
+
|
|
435
439
|
branch = get_current_git_branch(root_path)
|
|
436
440
|
if branch:
|
|
437
441
|
git_info["branch"] = branch
|
|
438
|
-
|
|
442
|
+
|
|
439
443
|
commit_hash = get_git_commit_hash(root_path)
|
|
440
444
|
if commit_hash:
|
|
441
445
|
git_info["commit"] = commit_hash
|
|
442
|
-
|
|
446
|
+
|
|
443
447
|
is_clean = is_working_directory_clean(root_path)
|
|
444
448
|
git_info["clean"] = "yes" if is_clean else "no (uncommitted changes)"
|
|
445
|
-
|
|
449
|
+
|
|
446
450
|
tracked, untracked = get_git_status_files(root_path)
|
|
447
451
|
if tracked or untracked:
|
|
448
452
|
status_info = []
|
|
@@ -18,6 +18,7 @@ from ripperdoc.core.tool import (
|
|
|
18
18
|
ValidationResult,
|
|
19
19
|
)
|
|
20
20
|
from ripperdoc.utils.log import get_logger
|
|
21
|
+
from ripperdoc.utils.file_watch import record_snapshot
|
|
21
22
|
|
|
22
23
|
logger = get_logger()
|
|
23
24
|
|
|
@@ -360,6 +361,17 @@ class MultiEditTool(Tool[MultiEditToolInput, MultiEditToolOutput]):
|
|
|
360
361
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
361
362
|
try:
|
|
362
363
|
file_path.write_text(updated_content, encoding="utf-8")
|
|
364
|
+
try:
|
|
365
|
+
record_snapshot(
|
|
366
|
+
str(file_path),
|
|
367
|
+
updated_content,
|
|
368
|
+
getattr(context, "file_state_cache", {}),
|
|
369
|
+
)
|
|
370
|
+
except Exception:
|
|
371
|
+
logger.exception(
|
|
372
|
+
"[multi_edit_tool] Failed to record file snapshot",
|
|
373
|
+
extra={"file_path": str(file_path)},
|
|
374
|
+
)
|
|
363
375
|
except Exception as exc:
|
|
364
376
|
logger.exception(
|
|
365
377
|
"[multi_edit_tool] Error writing edited file",
|
|
@@ -20,6 +20,7 @@ from ripperdoc.core.tool import (
|
|
|
20
20
|
ValidationResult,
|
|
21
21
|
)
|
|
22
22
|
from ripperdoc.utils.log import get_logger
|
|
23
|
+
from ripperdoc.utils.file_watch import record_snapshot
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
logger = get_logger()
|
|
@@ -272,6 +273,17 @@ class NotebookEditTool(Tool[NotebookEditInput, NotebookEditOutput]):
|
|
|
272
273
|
)
|
|
273
274
|
|
|
274
275
|
path.write_text(json.dumps(nb_json, indent=1), encoding="utf-8")
|
|
276
|
+
try:
|
|
277
|
+
record_snapshot(
|
|
278
|
+
input_data.notebook_path,
|
|
279
|
+
json.dumps(nb_json, indent=1),
|
|
280
|
+
getattr(context, "file_state_cache", {}),
|
|
281
|
+
)
|
|
282
|
+
except Exception:
|
|
283
|
+
logger.exception(
|
|
284
|
+
"[notebook_edit_tool] Failed to record file snapshot",
|
|
285
|
+
extra={"file_path": input_data.notebook_path},
|
|
286
|
+
)
|
|
275
287
|
|
|
276
288
|
output = NotebookEditOutput(
|
|
277
289
|
new_source=new_source,
|