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/tools/todo_tool.py
CHANGED
|
@@ -361,9 +361,7 @@ class TodoWriteTool(Tool[TodoWriteToolInput, TodoToolOutput]):
|
|
|
361
361
|
)
|
|
362
362
|
yield ToolResult(data=output, result_for_assistant=result_text)
|
|
363
363
|
except Exception as exc:
|
|
364
|
-
logger.exception(
|
|
365
|
-
"[todo_tool] Error updating todos", extra={"error": str(exc)}
|
|
366
|
-
)
|
|
364
|
+
logger.exception("[todo_tool] Error updating todos", extra={"error": str(exc)})
|
|
367
365
|
error = f"Error updating todos: {exc}"
|
|
368
366
|
yield ToolResult(
|
|
369
367
|
data=TodoToolOutput(
|
|
@@ -119,7 +119,9 @@ class ToolSearchTool(Tool[ToolSearchInput, ToolSearchOutput]):
|
|
|
119
119
|
def is_concurrency_safe(self) -> bool:
|
|
120
120
|
return True
|
|
121
121
|
|
|
122
|
-
def needs_permissions(
|
|
122
|
+
def needs_permissions(
|
|
123
|
+
self, input_data: Optional[ToolSearchInput] = None
|
|
124
|
+
) -> bool: # noqa: ARG002
|
|
123
125
|
return False
|
|
124
126
|
|
|
125
127
|
async def validate_input(
|
|
@@ -280,9 +282,11 @@ class ToolSearchTool(Tool[ToolSearchInput, ToolSearchOutput]):
|
|
|
280
282
|
"name": name,
|
|
281
283
|
"user_facing_name": tool.user_facing_name(),
|
|
282
284
|
"description": description,
|
|
283
|
-
"active":
|
|
284
|
-
|
|
285
|
-
|
|
285
|
+
"active": (
|
|
286
|
+
getattr(registry, "is_active", lambda *_: False)(name)
|
|
287
|
+
if hasattr(registry, "is_active")
|
|
288
|
+
else False
|
|
289
|
+
),
|
|
286
290
|
"deferred": name in getattr(registry, "deferred_names", set()),
|
|
287
291
|
"score": 0.0,
|
|
288
292
|
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Lightweight file-change tracking for notifying the model about user edits."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from ripperdoc.utils.log import get_logger
|
|
11
|
+
|
|
12
|
+
logger = get_logger()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class FileSnapshot:
|
|
17
|
+
"""Snapshot of a file read by the agent."""
|
|
18
|
+
|
|
19
|
+
content: str
|
|
20
|
+
timestamp: float
|
|
21
|
+
offset: int = 0
|
|
22
|
+
limit: Optional[int] = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ChangedFileNotice:
|
|
27
|
+
"""Information about a file that changed after it was read."""
|
|
28
|
+
|
|
29
|
+
file_path: str
|
|
30
|
+
summary: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def record_snapshot(
|
|
34
|
+
file_path: str,
|
|
35
|
+
content: str,
|
|
36
|
+
cache: Dict[str, FileSnapshot],
|
|
37
|
+
*,
|
|
38
|
+
offset: int = 0,
|
|
39
|
+
limit: Optional[int] = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Store the current contents and mtime for a file."""
|
|
42
|
+
try:
|
|
43
|
+
timestamp = os.path.getmtime(file_path)
|
|
44
|
+
except OSError:
|
|
45
|
+
timestamp = 0.0
|
|
46
|
+
cache[file_path] = FileSnapshot(
|
|
47
|
+
content=content, timestamp=timestamp, offset=offset, limit=limit
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _read_portion(file_path: str, offset: int, limit: Optional[int]) -> str:
|
|
52
|
+
with open(file_path, "r", encoding="utf-8", errors="replace") as handle:
|
|
53
|
+
lines = handle.readlines()
|
|
54
|
+
start = max(offset, 0)
|
|
55
|
+
if limit is None:
|
|
56
|
+
selected = lines[start:]
|
|
57
|
+
else:
|
|
58
|
+
selected = lines[start : start + limit]
|
|
59
|
+
return "".join(selected)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _build_diff_summary(old_content: str, new_content: str, file_path: str, max_lines: int) -> str:
|
|
63
|
+
diff = list(
|
|
64
|
+
difflib.unified_diff(
|
|
65
|
+
old_content.splitlines(),
|
|
66
|
+
new_content.splitlines(),
|
|
67
|
+
fromfile=file_path,
|
|
68
|
+
tofile=file_path,
|
|
69
|
+
lineterm="",
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
if not diff:
|
|
73
|
+
return "File was modified but contents appear unchanged."
|
|
74
|
+
|
|
75
|
+
# Keep the diff short to avoid flooding the model.
|
|
76
|
+
if len(diff) > max_lines:
|
|
77
|
+
diff = diff[:max_lines] + ["... (diff truncated)"]
|
|
78
|
+
return "\n".join(diff)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def detect_changed_files(
|
|
82
|
+
cache: Dict[str, FileSnapshot], *, max_diff_lines: int = 80
|
|
83
|
+
) -> List[ChangedFileNotice]:
|
|
84
|
+
"""Return notices for files whose mtime increased since they were read."""
|
|
85
|
+
notices: List[ChangedFileNotice] = []
|
|
86
|
+
|
|
87
|
+
# Iterate over a static list so we can mutate cache safely.
|
|
88
|
+
for file_path, snapshot in list(cache.items()):
|
|
89
|
+
try:
|
|
90
|
+
current_mtime = os.path.getmtime(file_path)
|
|
91
|
+
except OSError:
|
|
92
|
+
notices.append(
|
|
93
|
+
ChangedFileNotice(
|
|
94
|
+
file_path=file_path, summary="File was deleted or is no longer accessible."
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
cache.pop(file_path, None)
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
if current_mtime <= snapshot.timestamp:
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
new_content = _read_portion(file_path, snapshot.offset, snapshot.limit)
|
|
105
|
+
except Exception as exc: # pragma: no cover - best-effort telemetry
|
|
106
|
+
logger.exception(
|
|
107
|
+
"[file_watch] Failed reading changed file",
|
|
108
|
+
extra={"file_path": file_path, "error": str(exc)},
|
|
109
|
+
)
|
|
110
|
+
notices.append(
|
|
111
|
+
ChangedFileNotice(
|
|
112
|
+
file_path=file_path,
|
|
113
|
+
summary=f"File changed but could not be read: {exc}",
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
# Avoid spamming repeated errors by updating timestamp.
|
|
117
|
+
snapshot.timestamp = current_mtime
|
|
118
|
+
cache[file_path] = snapshot
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
diff_summary = _build_diff_summary(
|
|
122
|
+
snapshot.content, new_content, file_path, max_lines=max_diff_lines
|
|
123
|
+
)
|
|
124
|
+
notices.append(ChangedFileNotice(file_path=file_path, summary=diff_summary))
|
|
125
|
+
# Update snapshot so we only notify on subsequent changes.
|
|
126
|
+
record_snapshot(
|
|
127
|
+
file_path,
|
|
128
|
+
new_content,
|
|
129
|
+
cache,
|
|
130
|
+
offset=snapshot.offset,
|
|
131
|
+
limit=snapshot.limit,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
return notices
|
ripperdoc/utils/git_utils.py
CHANGED
|
@@ -42,10 +42,10 @@ def read_gitignore_patterns(path: Path) -> List[str]:
|
|
|
42
42
|
"""Read .gitignore patterns from a directory and its parent directories."""
|
|
43
43
|
patterns: List[str] = []
|
|
44
44
|
current = path
|
|
45
|
-
|
|
45
|
+
|
|
46
46
|
# Read .gitignore from current directory up to git root
|
|
47
47
|
git_root = get_git_root(path)
|
|
48
|
-
|
|
48
|
+
|
|
49
49
|
while current and (git_root is None or current.is_relative_to(git_root)):
|
|
50
50
|
gitignore_file = current / ".gitignore"
|
|
51
51
|
if gitignore_file.exists():
|
|
@@ -57,7 +57,7 @@ def read_gitignore_patterns(path: Path) -> List[str]:
|
|
|
57
57
|
patterns.append(line)
|
|
58
58
|
except (IOError, UnicodeDecodeError):
|
|
59
59
|
pass
|
|
60
|
-
|
|
60
|
+
|
|
61
61
|
# Also check for .git/info/exclude
|
|
62
62
|
git_info_exclude = current / ".git" / "info" / "exclude"
|
|
63
63
|
if git_info_exclude.exists():
|
|
@@ -69,11 +69,11 @@ def read_gitignore_patterns(path: Path) -> List[str]:
|
|
|
69
69
|
patterns.append(line)
|
|
70
70
|
except (IOError, UnicodeDecodeError):
|
|
71
71
|
pass
|
|
72
|
-
|
|
72
|
+
|
|
73
73
|
if current.parent == current: # Reached root
|
|
74
74
|
break
|
|
75
75
|
current = current.parent
|
|
76
|
-
|
|
76
|
+
|
|
77
77
|
# Add global gitignore patterns
|
|
78
78
|
global_gitignore = Path.home() / ".gitignore"
|
|
79
79
|
if global_gitignore.exists():
|
|
@@ -85,39 +85,39 @@ def read_gitignore_patterns(path: Path) -> List[str]:
|
|
|
85
85
|
patterns.append(line)
|
|
86
86
|
except (IOError, UnicodeDecodeError):
|
|
87
87
|
pass
|
|
88
|
-
|
|
88
|
+
|
|
89
89
|
return patterns
|
|
90
90
|
|
|
91
91
|
|
|
92
92
|
def parse_gitignore_pattern(pattern: str, root_path: Path) -> Tuple[str, Optional[Path]]:
|
|
93
93
|
"""Parse a gitignore pattern and return (relative_pattern, root)."""
|
|
94
94
|
pattern = pattern.strip()
|
|
95
|
-
|
|
95
|
+
|
|
96
96
|
# Handle absolute paths
|
|
97
97
|
if pattern.startswith("/"):
|
|
98
98
|
return pattern[1:], root_path
|
|
99
|
-
|
|
99
|
+
|
|
100
100
|
# Handle patterns relative to home directory
|
|
101
101
|
if pattern.startswith("~/"):
|
|
102
102
|
home_pattern = pattern[2:]
|
|
103
103
|
return home_pattern, Path.home()
|
|
104
|
-
|
|
104
|
+
|
|
105
105
|
# Handle patterns with leading slash (relative to repository root)
|
|
106
106
|
if pattern.startswith("/"):
|
|
107
107
|
return pattern[1:], root_path
|
|
108
|
-
|
|
108
|
+
|
|
109
109
|
# Default: pattern is relative to the directory containing .gitignore
|
|
110
110
|
return pattern, None
|
|
111
111
|
|
|
112
112
|
|
|
113
113
|
def build_ignore_patterns_map(
|
|
114
|
-
root_path: Path,
|
|
114
|
+
root_path: Path,
|
|
115
115
|
user_ignore_patterns: Optional[List[str]] = None,
|
|
116
|
-
include_gitignore: bool = True
|
|
116
|
+
include_gitignore: bool = True,
|
|
117
117
|
) -> Dict[Optional[Path], List[str]]:
|
|
118
118
|
"""Build a map of ignore patterns by root directory."""
|
|
119
119
|
ignore_map: Dict[Optional[Path], List[str]] = {}
|
|
120
|
-
|
|
120
|
+
|
|
121
121
|
# Add user-provided ignore patterns
|
|
122
122
|
if user_ignore_patterns:
|
|
123
123
|
for pattern in user_ignore_patterns:
|
|
@@ -125,7 +125,7 @@ def build_ignore_patterns_map(
|
|
|
125
125
|
if pattern_root not in ignore_map:
|
|
126
126
|
ignore_map[pattern_root] = []
|
|
127
127
|
ignore_map[pattern_root].append(relative_pattern)
|
|
128
|
-
|
|
128
|
+
|
|
129
129
|
# Add .gitignore patterns
|
|
130
130
|
if include_gitignore and is_git_repository(root_path):
|
|
131
131
|
gitignore_patterns = read_gitignore_patterns(root_path)
|
|
@@ -134,31 +134,29 @@ def build_ignore_patterns_map(
|
|
|
134
134
|
if pattern_root not in ignore_map:
|
|
135
135
|
ignore_map[pattern_root] = []
|
|
136
136
|
ignore_map[pattern_root].append(relative_pattern)
|
|
137
|
-
|
|
137
|
+
|
|
138
138
|
return ignore_map
|
|
139
139
|
|
|
140
140
|
|
|
141
141
|
def should_ignore_path(
|
|
142
|
-
path: Path,
|
|
143
|
-
root_path: Path,
|
|
144
|
-
ignore_map: Dict[Optional[Path], List[str]]
|
|
142
|
+
path: Path, root_path: Path, ignore_map: Dict[Optional[Path], List[str]]
|
|
145
143
|
) -> bool:
|
|
146
144
|
"""Check if a path should be ignored based on ignore patterns."""
|
|
147
145
|
# Check against each root in the ignore map
|
|
148
146
|
for pattern_root, patterns in ignore_map.items():
|
|
149
147
|
# Determine the actual root to use for pattern matching
|
|
150
148
|
actual_root = pattern_root if pattern_root is not None else root_path
|
|
151
|
-
|
|
149
|
+
|
|
152
150
|
try:
|
|
153
151
|
# Get relative path from actual_root
|
|
154
152
|
rel_path = path.relative_to(actual_root).as_posix()
|
|
155
153
|
except ValueError:
|
|
156
154
|
# Path is not under this root, skip
|
|
157
155
|
continue
|
|
158
|
-
|
|
156
|
+
|
|
159
157
|
# For directories, also check with trailing slash
|
|
160
158
|
rel_path_dir = f"{rel_path}/" if path.is_dir() else rel_path
|
|
161
|
-
|
|
159
|
+
|
|
162
160
|
# Check each pattern
|
|
163
161
|
for pattern in patterns:
|
|
164
162
|
# Handle directory-specific patterns
|
|
@@ -166,14 +164,14 @@ def should_ignore_path(
|
|
|
166
164
|
if not path.is_dir():
|
|
167
165
|
continue
|
|
168
166
|
pattern_without_slash = pattern[:-1]
|
|
169
|
-
if fnmatch.fnmatch(rel_path, pattern_without_slash) or
|
|
170
|
-
|
|
167
|
+
if fnmatch.fnmatch(rel_path, pattern_without_slash) or fnmatch.fnmatch(
|
|
168
|
+
rel_path_dir, pattern
|
|
169
|
+
):
|
|
171
170
|
return True
|
|
172
171
|
else:
|
|
173
|
-
if fnmatch.fnmatch(rel_path, pattern) or
|
|
174
|
-
fnmatch.fnmatch(rel_path_dir, pattern):
|
|
172
|
+
if fnmatch.fnmatch(rel_path, pattern) or fnmatch.fnmatch(rel_path_dir, pattern):
|
|
175
173
|
return True
|
|
176
|
-
|
|
174
|
+
|
|
177
175
|
return False
|
|
178
176
|
|
|
179
177
|
|
|
@@ -181,10 +179,10 @@ def get_git_status_files(root_path: Path) -> Tuple[List[str], List[str]]:
|
|
|
181
179
|
"""Get tracked and untracked files from git status."""
|
|
182
180
|
tracked: List[str] = []
|
|
183
181
|
untracked: List[str] = []
|
|
184
|
-
|
|
182
|
+
|
|
185
183
|
if not is_git_repository(root_path):
|
|
186
184
|
return tracked, untracked
|
|
187
|
-
|
|
185
|
+
|
|
188
186
|
try:
|
|
189
187
|
# Get tracked files (modified, added, etc.)
|
|
190
188
|
result = subprocess.run(
|
|
@@ -194,25 +192,25 @@ def get_git_status_files(root_path: Path) -> Tuple[List[str], List[str]]:
|
|
|
194
192
|
text=True,
|
|
195
193
|
timeout=10,
|
|
196
194
|
)
|
|
197
|
-
|
|
195
|
+
|
|
198
196
|
if result.returncode == 0:
|
|
199
197
|
for line in result.stdout.strip().split("\n"):
|
|
200
198
|
if line:
|
|
201
199
|
status = line[:2].strip()
|
|
202
200
|
file_path = line[3:].strip()
|
|
203
|
-
|
|
201
|
+
|
|
204
202
|
# Remove quotes if present
|
|
205
203
|
if file_path.startswith('"') and file_path.endswith('"'):
|
|
206
204
|
file_path = file_path[1:-1]
|
|
207
|
-
|
|
205
|
+
|
|
208
206
|
if status == "??": # Untracked
|
|
209
207
|
untracked.append(file_path)
|
|
210
208
|
else: # Tracked (modified, added, etc.)
|
|
211
209
|
tracked.append(file_path)
|
|
212
|
-
|
|
210
|
+
|
|
213
211
|
except (subprocess.SubprocessError, FileNotFoundError):
|
|
214
212
|
pass
|
|
215
|
-
|
|
213
|
+
|
|
216
214
|
return tracked, untracked
|
|
217
215
|
|
|
218
216
|
|
|
@@ -220,7 +218,7 @@ def get_current_git_branch(root_path: Path) -> Optional[str]:
|
|
|
220
218
|
"""Get the current git branch name."""
|
|
221
219
|
if not is_git_repository(root_path):
|
|
222
220
|
return None
|
|
223
|
-
|
|
221
|
+
|
|
224
222
|
try:
|
|
225
223
|
result = subprocess.run(
|
|
226
224
|
["git", "branch", "--show-current"],
|
|
@@ -233,7 +231,7 @@ def get_current_git_branch(root_path: Path) -> Optional[str]:
|
|
|
233
231
|
return result.stdout.strip()
|
|
234
232
|
except (subprocess.SubprocessError, FileNotFoundError):
|
|
235
233
|
pass
|
|
236
|
-
|
|
234
|
+
|
|
237
235
|
return None
|
|
238
236
|
|
|
239
237
|
|
|
@@ -241,7 +239,7 @@ def get_git_commit_hash(root_path: Path) -> Optional[str]:
|
|
|
241
239
|
"""Get the current git commit hash."""
|
|
242
240
|
if not is_git_repository(root_path):
|
|
243
241
|
return None
|
|
244
|
-
|
|
242
|
+
|
|
245
243
|
try:
|
|
246
244
|
result = subprocess.run(
|
|
247
245
|
["git", "rev-parse", "HEAD"],
|
|
@@ -254,7 +252,7 @@ def get_git_commit_hash(root_path: Path) -> Optional[str]:
|
|
|
254
252
|
return result.stdout.strip()[:8] # Short hash
|
|
255
253
|
except (subprocess.SubprocessError, FileNotFoundError):
|
|
256
254
|
pass
|
|
257
|
-
|
|
255
|
+
|
|
258
256
|
return None
|
|
259
257
|
|
|
260
258
|
|
|
@@ -262,7 +260,7 @@ def is_working_directory_clean(root_path: Path) -> bool:
|
|
|
262
260
|
"""Check if the working directory is clean (no uncommitted changes)."""
|
|
263
261
|
if not is_git_repository(root_path):
|
|
264
262
|
return True
|
|
265
|
-
|
|
263
|
+
|
|
266
264
|
try:
|
|
267
265
|
result = subprocess.run(
|
|
268
266
|
["git", "status", "--porcelain"],
|
ripperdoc/utils/json_utils.py
CHANGED
|
@@ -12,8 +12,7 @@ logger = get_logger()
|
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
def safe_parse_json(json_text: Optional[str], log_error: bool = True) -> Optional[Any]:
|
|
15
|
-
"""Best-effort JSON.parse wrapper that returns None on failure.
|
|
16
|
-
"""
|
|
15
|
+
"""Best-effort JSON.parse wrapper that returns None on failure."""
|
|
17
16
|
if not json_text:
|
|
18
17
|
return None
|
|
19
18
|
try:
|
ripperdoc/utils/log.py
CHANGED
|
@@ -54,9 +54,7 @@ class StructuredFormatter(logging.Formatter):
|
|
|
54
54
|
}
|
|
55
55
|
if extras:
|
|
56
56
|
try:
|
|
57
|
-
serialized = json.dumps(
|
|
58
|
-
extras, sort_keys=True, ensure_ascii=True, default=str
|
|
59
|
-
)
|
|
57
|
+
serialized = json.dumps(extras, sort_keys=True, ensure_ascii=True, default=str)
|
|
60
58
|
except Exception:
|
|
61
59
|
serialized = str(extras)
|
|
62
60
|
return f"{message} | {serialized}"
|
|
@@ -103,7 +101,8 @@ class RipperdocLogger:
|
|
|
103
101
|
# Swallow errors while rotating handlers; console logging should continue.
|
|
104
102
|
self.logger.exception("[logging] Failed to remove existing file handler")
|
|
105
103
|
|
|
106
|
-
|
|
104
|
+
# Use UTF-8 to avoid Windows code page encoding errors when logs contain non-ASCII text.
|
|
105
|
+
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
|
107
106
|
file_handler.setLevel(logging.DEBUG)
|
|
108
107
|
file_formatter = StructuredFormatter("%(asctime)s [%(levelname)s] %(message)s")
|
|
109
108
|
file_handler.setFormatter(file_formatter)
|
ripperdoc/utils/memory.py
CHANGED
|
@@ -72,9 +72,7 @@ def _read_file_with_type(file_path: Path, file_type: str) -> Optional[MemoryFile
|
|
|
72
72
|
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
|
73
73
|
return MemoryFile(path=str(file_path), type=file_type, content=content)
|
|
74
74
|
except PermissionError:
|
|
75
|
-
logger.exception(
|
|
76
|
-
"[memory] Permission error reading file", extra={"path": str(file_path)}
|
|
77
|
-
)
|
|
75
|
+
logger.exception("[memory] Permission error reading file", extra={"path": str(file_path)})
|
|
78
76
|
return None
|
|
79
77
|
except OSError:
|
|
80
78
|
logger.exception("[memory] OS error reading file", extra={"path": str(file_path)})
|
|
@@ -402,9 +402,7 @@ def find_latest_assistant_usage_tokens(
|
|
|
402
402
|
if tokens > 0:
|
|
403
403
|
return tokens
|
|
404
404
|
except Exception:
|
|
405
|
-
logger.debug(
|
|
406
|
-
"[message_compaction] Failed to parse usage tokens", exc_info=True
|
|
407
|
-
)
|
|
405
|
+
logger.debug("[message_compaction] Failed to parse usage tokens", exc_info=True)
|
|
408
406
|
continue
|
|
409
407
|
return 0
|
|
410
408
|
|
|
@@ -441,9 +439,7 @@ def _run_cleanup_callbacks() -> None:
|
|
|
441
439
|
try:
|
|
442
440
|
callback()
|
|
443
441
|
except Exception as exc:
|
|
444
|
-
logger.debug(
|
|
445
|
-
f"[message_compaction] Cleanup callback failed: {exc}", exc_info=True
|
|
446
|
-
)
|
|
442
|
+
logger.debug(f"[message_compaction] Cleanup callback failed: {exc}", exc_info=True)
|
|
447
443
|
|
|
448
444
|
|
|
449
445
|
def _normalize_tool_use_id(block: Any) -> str:
|
ripperdoc/utils/messages.py
CHANGED
|
@@ -31,7 +31,7 @@ class MessageContent(BaseModel):
|
|
|
31
31
|
id: Optional[str] = None
|
|
32
32
|
tool_use_id: Optional[str] = None
|
|
33
33
|
name: Optional[str] = None
|
|
34
|
-
input: Optional[Dict[str,
|
|
34
|
+
input: Optional[Dict[str, object]] = None
|
|
35
35
|
is_error: Optional[bool] = None
|
|
36
36
|
|
|
37
37
|
|
|
@@ -120,7 +120,7 @@ class Message(BaseModel):
|
|
|
120
120
|
content: Union[str, List[MessageContent]]
|
|
121
121
|
uuid: str = ""
|
|
122
122
|
|
|
123
|
-
def __init__(self, **data:
|
|
123
|
+
def __init__(self, **data: object) -> None:
|
|
124
124
|
if "uuid" not in data or not data["uuid"]:
|
|
125
125
|
data["uuid"] = str(uuid4())
|
|
126
126
|
super().__init__(**data)
|
|
@@ -132,9 +132,9 @@ class UserMessage(BaseModel):
|
|
|
132
132
|
type: str = "user"
|
|
133
133
|
message: Message
|
|
134
134
|
uuid: str = ""
|
|
135
|
-
tool_use_result: Optional[
|
|
135
|
+
tool_use_result: Optional[object] = None
|
|
136
136
|
|
|
137
|
-
def __init__(self, **data:
|
|
137
|
+
def __init__(self, **data: object) -> None:
|
|
138
138
|
if "uuid" not in data or not data["uuid"]:
|
|
139
139
|
data["uuid"] = str(uuid4())
|
|
140
140
|
super().__init__(**data)
|
|
@@ -150,7 +150,7 @@ class AssistantMessage(BaseModel):
|
|
|
150
150
|
duration_ms: float = 0.0
|
|
151
151
|
is_api_error_message: bool = False
|
|
152
152
|
|
|
153
|
-
def __init__(self, **data:
|
|
153
|
+
def __init__(self, **data: object) -> None:
|
|
154
154
|
if "uuid" not in data or not data["uuid"]:
|
|
155
155
|
data["uuid"] = str(uuid4())
|
|
156
156
|
super().__init__(**data)
|
|
@@ -167,14 +167,14 @@ class ProgressMessage(BaseModel):
|
|
|
167
167
|
sibling_tool_use_ids: set[str] = set()
|
|
168
168
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
169
169
|
|
|
170
|
-
def __init__(self, **data:
|
|
170
|
+
def __init__(self, **data: object) -> None:
|
|
171
171
|
if "uuid" not in data or not data["uuid"]:
|
|
172
172
|
data["uuid"] = str(uuid4())
|
|
173
173
|
super().__init__(**data)
|
|
174
174
|
|
|
175
175
|
|
|
176
176
|
def create_user_message(
|
|
177
|
-
content: Union[str, List[Dict[str, Any]]], tool_use_result: Optional[
|
|
177
|
+
content: Union[str, List[Dict[str, Any]]], tool_use_result: Optional[object] = None
|
|
178
178
|
) -> UserMessage:
|
|
179
179
|
"""Create a user message."""
|
|
180
180
|
if isinstance(content, str):
|
|
@@ -371,9 +371,7 @@ def normalize_messages_for_api(
|
|
|
371
371
|
api_blocks.append(_content_block_to_api(block))
|
|
372
372
|
normalized.append({"role": "user", "content": api_blocks})
|
|
373
373
|
else:
|
|
374
|
-
normalized.append(
|
|
375
|
-
{"role": "user", "content": user_content} # type: ignore
|
|
376
|
-
)
|
|
374
|
+
normalized.append({"role": "user", "content": user_content}) # type: ignore
|
|
377
375
|
elif msg_type == "assistant":
|
|
378
376
|
asst_content = _msg_content(msg)
|
|
379
377
|
if isinstance(asst_content, list):
|
|
@@ -428,9 +426,7 @@ def normalize_messages_for_api(
|
|
|
428
426
|
api_blocks.append(_content_block_to_api(block))
|
|
429
427
|
normalized.append({"role": "assistant", "content": api_blocks})
|
|
430
428
|
else:
|
|
431
|
-
normalized.append(
|
|
432
|
-
{"role": "assistant", "content": asst_content} # type: ignore
|
|
433
|
-
)
|
|
429
|
+
normalized.append({"role": "assistant", "content": asst_content}) # type: ignore
|
|
434
430
|
|
|
435
431
|
logger.debug(
|
|
436
432
|
f"[normalize_messages_for_api] protocol={protocol} tool_mode={effective_tool_mode} "
|
ripperdoc/utils/output_utils.py
CHANGED
|
@@ -151,9 +151,7 @@ def truncate_output(text: str, max_chars: int = MAX_OUTPUT_CHARS) -> dict[str, A
|
|
|
151
151
|
available = max(0, max_chars - len(marker))
|
|
152
152
|
keep_start = min(TRUNCATE_KEEP_START, available // 2)
|
|
153
153
|
keep_end = min(TRUNCATE_KEEP_END, available - keep_start)
|
|
154
|
-
marker = _choose_marker(
|
|
155
|
-
max(0, original_length - (keep_start + keep_end)), max_chars
|
|
156
|
-
)
|
|
154
|
+
marker = _choose_marker(max(0, original_length - (keep_start + keep_end)), max_chars)
|
|
157
155
|
|
|
158
156
|
available = max(0, max_chars - len(marker))
|
|
159
157
|
# Ensure kept sections fit the final budget; trim end first, then start if needed.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Prompt helpers for interactive input."""
|
|
2
|
+
|
|
3
|
+
from getpass import getpass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def prompt_secret(prompt_text: str, prompt_suffix: str = ": ") -> str:
|
|
7
|
+
"""Prompt for sensitive input, masking characters when possible.
|
|
8
|
+
|
|
9
|
+
Falls back to getpass (no echo) if prompt_toolkit is unavailable.
|
|
10
|
+
"""
|
|
11
|
+
full_prompt = f"{prompt_text}{prompt_suffix}"
|
|
12
|
+
try:
|
|
13
|
+
from prompt_toolkit import prompt as pt_prompt
|
|
14
|
+
|
|
15
|
+
return pt_prompt(full_prompt, is_password=True)
|
|
16
|
+
except Exception:
|
|
17
|
+
return getpass(full_prompt)
|
ripperdoc/utils/session_usage.py
CHANGED
|
@@ -17,6 +17,7 @@ class ModelUsage:
|
|
|
17
17
|
cache_creation_input_tokens: int = 0
|
|
18
18
|
requests: int = 0
|
|
19
19
|
duration_ms: float = 0.0
|
|
20
|
+
cost_usd: float = 0.0
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
@dataclass
|
|
@@ -49,6 +50,10 @@ class SessionUsage:
|
|
|
49
50
|
def total_duration_ms(self) -> float:
|
|
50
51
|
return sum(usage.duration_ms for usage in self.models.values())
|
|
51
52
|
|
|
53
|
+
@property
|
|
54
|
+
def total_cost_usd(self) -> float:
|
|
55
|
+
return sum(usage.cost_usd for usage in self.models.values())
|
|
56
|
+
|
|
52
57
|
|
|
53
58
|
_SESSION_USAGE = SessionUsage()
|
|
54
59
|
|
|
@@ -76,6 +81,7 @@ def record_usage(
|
|
|
76
81
|
cache_read_input_tokens: int = 0,
|
|
77
82
|
cache_creation_input_tokens: int = 0,
|
|
78
83
|
duration_ms: float = 0.0,
|
|
84
|
+
cost_usd: float = 0.0,
|
|
79
85
|
) -> None:
|
|
80
86
|
"""Record a single model invocation."""
|
|
81
87
|
global _SESSION_USAGE
|
|
@@ -88,6 +94,7 @@ def record_usage(
|
|
|
88
94
|
usage.cache_creation_input_tokens += _as_int(cache_creation_input_tokens)
|
|
89
95
|
usage.duration_ms += float(duration_ms) if duration_ms and duration_ms > 0 else 0.0
|
|
90
96
|
usage.requests += 1
|
|
97
|
+
usage.cost_usd += float(cost_usd) if cost_usd and cost_usd > 0 else 0.0
|
|
91
98
|
|
|
92
99
|
|
|
93
100
|
def get_session_usage() -> SessionUsage:
|