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,117 @@
|
|
|
1
|
+
"""Session-level usage tracking for model calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Dict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class ModelUsage:
|
|
12
|
+
"""Aggregate token and duration stats for a single model."""
|
|
13
|
+
|
|
14
|
+
input_tokens: int = 0
|
|
15
|
+
output_tokens: int = 0
|
|
16
|
+
cache_read_input_tokens: int = 0
|
|
17
|
+
cache_creation_input_tokens: int = 0
|
|
18
|
+
requests: int = 0
|
|
19
|
+
duration_ms: float = 0.0
|
|
20
|
+
cost_usd: float = 0.0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class SessionUsage:
|
|
25
|
+
"""In-memory snapshot of usage for the current session."""
|
|
26
|
+
|
|
27
|
+
models: Dict[str, ModelUsage] = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def total_input_tokens(self) -> int:
|
|
31
|
+
return sum(usage.input_tokens for usage in self.models.values())
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def total_output_tokens(self) -> int:
|
|
35
|
+
return sum(usage.output_tokens for usage in self.models.values())
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def total_cache_read_tokens(self) -> int:
|
|
39
|
+
return sum(usage.cache_read_input_tokens for usage in self.models.values())
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def total_cache_creation_tokens(self) -> int:
|
|
43
|
+
return sum(usage.cache_creation_input_tokens for usage in self.models.values())
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def total_requests(self) -> int:
|
|
47
|
+
return sum(usage.requests for usage in self.models.values())
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def total_duration_ms(self) -> float:
|
|
51
|
+
return sum(usage.duration_ms for usage in self.models.values())
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def total_cost_usd(self) -> float:
|
|
55
|
+
return sum(usage.cost_usd for usage in self.models.values())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_SESSION_USAGE = SessionUsage()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _as_int(value: Any) -> int:
|
|
62
|
+
"""Best-effort integer conversion."""
|
|
63
|
+
try:
|
|
64
|
+
if value is None:
|
|
65
|
+
return 0
|
|
66
|
+
return int(value)
|
|
67
|
+
except (TypeError, ValueError):
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _model_key(model: str) -> str:
|
|
72
|
+
"""Normalize model names for use as dictionary keys."""
|
|
73
|
+
return model or "unknown"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def record_usage(
|
|
77
|
+
model: str,
|
|
78
|
+
*,
|
|
79
|
+
input_tokens: int = 0,
|
|
80
|
+
output_tokens: int = 0,
|
|
81
|
+
cache_read_input_tokens: int = 0,
|
|
82
|
+
cache_creation_input_tokens: int = 0,
|
|
83
|
+
duration_ms: float = 0.0,
|
|
84
|
+
cost_usd: float = 0.0,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Record a single model invocation."""
|
|
87
|
+
global _SESSION_USAGE
|
|
88
|
+
key = _model_key(model)
|
|
89
|
+
usage = _SESSION_USAGE.models.setdefault(key, ModelUsage())
|
|
90
|
+
|
|
91
|
+
usage.input_tokens += _as_int(input_tokens)
|
|
92
|
+
usage.output_tokens += _as_int(output_tokens)
|
|
93
|
+
usage.cache_read_input_tokens += _as_int(cache_read_input_tokens)
|
|
94
|
+
usage.cache_creation_input_tokens += _as_int(cache_creation_input_tokens)
|
|
95
|
+
usage.duration_ms += float(duration_ms) if duration_ms and duration_ms > 0 else 0.0
|
|
96
|
+
usage.requests += 1
|
|
97
|
+
usage.cost_usd += float(cost_usd) if cost_usd and cost_usd > 0 else 0.0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_session_usage() -> SessionUsage:
|
|
101
|
+
"""Return a copy of the current session usage."""
|
|
102
|
+
return deepcopy(_SESSION_USAGE)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def reset_session_usage() -> None:
|
|
106
|
+
"""Clear all recorded usage (primarily for tests)."""
|
|
107
|
+
global _SESSION_USAGE
|
|
108
|
+
_SESSION_USAGE = SessionUsage()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
__all__ = [
|
|
112
|
+
"ModelUsage",
|
|
113
|
+
"SessionUsage",
|
|
114
|
+
"get_session_usage",
|
|
115
|
+
"record_usage",
|
|
116
|
+
"reset_session_usage",
|
|
117
|
+
]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Shell token parsing utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
7
|
+
from typing import Iterable, List
|
|
8
|
+
|
|
9
|
+
# Operators and redirections that should not be treated as executable tokens.
|
|
10
|
+
SHELL_OPERATORS_WITH_REDIRECTION: set[str] = {
|
|
11
|
+
"|",
|
|
12
|
+
"||",
|
|
13
|
+
"&&",
|
|
14
|
+
";",
|
|
15
|
+
">",
|
|
16
|
+
">>",
|
|
17
|
+
"<",
|
|
18
|
+
"<<",
|
|
19
|
+
"2>",
|
|
20
|
+
"&>",
|
|
21
|
+
"2>&1",
|
|
22
|
+
"|&",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
_REDIRECTION_PATTERNS = (
|
|
26
|
+
re.compile(r"^\d?>?&\d+$"), # 2>&1, >&2, etc.
|
|
27
|
+
re.compile(r"^\d?>/dev/null$"), # 2>/dev/null, >/dev/null
|
|
28
|
+
re.compile(r"^/dev/null$"),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_shell_tokens(shell_command: str) -> List[str]:
|
|
33
|
+
"""Parse a shell command into tokens, preserving operators for inspection."""
|
|
34
|
+
if not shell_command:
|
|
35
|
+
return []
|
|
36
|
+
|
|
37
|
+
lexer = shlex.shlex(shell_command, posix=True)
|
|
38
|
+
lexer.whitespace_split = True
|
|
39
|
+
lexer.commenters = ""
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
return list(lexer)
|
|
43
|
+
except ValueError:
|
|
44
|
+
# Fall back to a coarse split to avoid hard failures.
|
|
45
|
+
return shell_command.split()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def filter_valid_tokens(tokens: Iterable[str]) -> list[str]:
|
|
49
|
+
"""Remove shell control operators and redirection tokens."""
|
|
50
|
+
return [token for token in tokens if token not in SHELL_OPERATORS_WITH_REDIRECTION]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_redirection_token(token: str) -> bool:
|
|
54
|
+
return any(pattern.match(token) for pattern in _REDIRECTION_PATTERNS)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse_and_clean_shell_tokens(raw_shell_string: str) -> List[str]:
|
|
58
|
+
"""Parse tokens and strip benign redirections to mirror reference cleaning."""
|
|
59
|
+
tokens = parse_shell_tokens(raw_shell_string)
|
|
60
|
+
if not tokens:
|
|
61
|
+
return []
|
|
62
|
+
|
|
63
|
+
cleaned: list[str] = []
|
|
64
|
+
skip_next = False
|
|
65
|
+
|
|
66
|
+
for idx, token in enumerate(tokens):
|
|
67
|
+
if skip_next:
|
|
68
|
+
skip_next = False
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
# Handle explicit redirection operators that are followed by a target.
|
|
72
|
+
if token in {">&", ">", "1>", "2>", ">>"}:
|
|
73
|
+
if idx + 1 < len(tokens):
|
|
74
|
+
next_token = tokens[idx + 1]
|
|
75
|
+
if _is_redirection_token(next_token):
|
|
76
|
+
skip_next = True
|
|
77
|
+
continue
|
|
78
|
+
cleaned.append(token)
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
# Skip inlined redirection tokens to /dev/null or file descriptors.
|
|
82
|
+
if _is_redirection_token(token):
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
cleaned.append(token)
|
|
86
|
+
|
|
87
|
+
return filter_valid_tokens(cleaned)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
__all__ = [
|
|
91
|
+
"parse_shell_tokens",
|
|
92
|
+
"parse_and_clean_shell_tokens",
|
|
93
|
+
"filter_valid_tokens",
|
|
94
|
+
"SHELL_OPERATORS_WITH_REDIRECTION",
|
|
95
|
+
]
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Shell detection helpers.
|
|
2
|
+
|
|
3
|
+
Selects a suitable interactive shell for running commands, preferring bash/zsh
|
|
4
|
+
over the system's /bin/sh default to ensure features like brace expansion.
|
|
5
|
+
On Windows, prefers Git Bash and falls back to cmd.exe if no bash is available.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
from typing import Iterable, List
|
|
13
|
+
|
|
14
|
+
from ripperdoc.utils.log import get_logger
|
|
15
|
+
|
|
16
|
+
logger = get_logger()
|
|
17
|
+
|
|
18
|
+
# Common locations to probe if shutil.which misses an otherwise standard path.
|
|
19
|
+
_COMMON_BIN_DIRS: tuple[str, ...] = ("/bin", "/usr/bin", "/usr/local/bin", "/opt/homebrew/bin")
|
|
20
|
+
_IS_WINDOWS = os.name == "nt"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _is_executable(path: str) -> bool:
|
|
24
|
+
return bool(path) and os.path.isfile(path) and os.access(path, os.X_OK)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _dedupe_preserve_order(items: Iterable[str]) -> list[str]:
|
|
28
|
+
seen = set()
|
|
29
|
+
ordered: list[str] = []
|
|
30
|
+
for item in items:
|
|
31
|
+
if item and item not in seen:
|
|
32
|
+
ordered.append(item)
|
|
33
|
+
seen.add(item)
|
|
34
|
+
return ordered
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _find_git_bash_windows() -> str | None:
|
|
38
|
+
env_path = os.environ.get("GIT_BASH_PATH") or os.environ.get("GITBASH")
|
|
39
|
+
if env_path and _is_executable(env_path):
|
|
40
|
+
return env_path
|
|
41
|
+
|
|
42
|
+
bash_in_path = shutil.which("bash")
|
|
43
|
+
if bash_in_path and "git" in bash_in_path.lower():
|
|
44
|
+
return bash_in_path
|
|
45
|
+
|
|
46
|
+
common = [
|
|
47
|
+
r"C:\Program Files\Git\bin\bash.exe",
|
|
48
|
+
r"C:\Program Files\Git\usr\bin\bash.exe",
|
|
49
|
+
r"C:\Program Files (x86)\Git\bin\bash.exe",
|
|
50
|
+
r"C:\Program Files (x86)\Git\usr\bin\bash.exe",
|
|
51
|
+
]
|
|
52
|
+
for path in common:
|
|
53
|
+
if _is_executable(path):
|
|
54
|
+
return path
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _windows_cmd_path() -> str | None:
|
|
59
|
+
comspec = os.environ.get("ComSpec")
|
|
60
|
+
if _is_executable(comspec or ""):
|
|
61
|
+
return comspec
|
|
62
|
+
which_cmd = shutil.which("cmd.exe") or shutil.which("cmd")
|
|
63
|
+
if which_cmd and _is_executable(which_cmd):
|
|
64
|
+
return which_cmd
|
|
65
|
+
system32 = os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "System32", "cmd.exe")
|
|
66
|
+
if _is_executable(system32):
|
|
67
|
+
return system32
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def find_suitable_shell() -> str:
|
|
72
|
+
"""Return a best-effort shell path, preferring bash/zsh (Git Bash on Windows).
|
|
73
|
+
|
|
74
|
+
Priority on Unix:
|
|
75
|
+
1) $SHELL if it's bash/zsh and executable
|
|
76
|
+
2) bash/zsh from PATH
|
|
77
|
+
3) bash/zsh in common bin directories
|
|
78
|
+
|
|
79
|
+
Priority on Windows:
|
|
80
|
+
1) Git Bash (env override or known locations / PATH)
|
|
81
|
+
2) cmd.exe as a last resort
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
RuntimeError: if no suitable shell is found.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
env_override = os.environ.get("RIPPERDOC_SHELL") or os.environ.get("RIPPERDOC_SHELL_PATH")
|
|
88
|
+
if env_override and _is_executable(env_override):
|
|
89
|
+
logger.debug("Using shell from RIPPERDOC_SHELL*: %s", env_override)
|
|
90
|
+
return env_override
|
|
91
|
+
|
|
92
|
+
current_shell = os.environ.get("SHELL", "")
|
|
93
|
+
current_is_bash = "bash" in current_shell
|
|
94
|
+
current_is_zsh = "zsh" in current_shell
|
|
95
|
+
|
|
96
|
+
if not _IS_WINDOWS:
|
|
97
|
+
if (current_is_bash or current_is_zsh) and _is_executable(current_shell):
|
|
98
|
+
logger.debug("Using SHELL from environment: %s", current_shell)
|
|
99
|
+
return current_shell
|
|
100
|
+
|
|
101
|
+
bash_path = shutil.which("bash") or ""
|
|
102
|
+
zsh_path = shutil.which("zsh") or ""
|
|
103
|
+
preferred_order = ["bash", "zsh"] if current_is_bash else ["zsh", "bash"]
|
|
104
|
+
|
|
105
|
+
candidates: list[str] = []
|
|
106
|
+
for name in preferred_order:
|
|
107
|
+
if name == "bash" and bash_path:
|
|
108
|
+
candidates.append(bash_path)
|
|
109
|
+
if name == "zsh" and zsh_path:
|
|
110
|
+
candidates.append(zsh_path)
|
|
111
|
+
|
|
112
|
+
for bin_dir in _COMMON_BIN_DIRS:
|
|
113
|
+
candidates.append(os.path.join(bin_dir, "bash"))
|
|
114
|
+
candidates.append(os.path.join(bin_dir, "zsh"))
|
|
115
|
+
|
|
116
|
+
for candidate in _dedupe_preserve_order(candidates):
|
|
117
|
+
if _is_executable(candidate):
|
|
118
|
+
logger.debug("Selected shell: %s", candidate)
|
|
119
|
+
return candidate
|
|
120
|
+
|
|
121
|
+
error_message = (
|
|
122
|
+
"No suitable shell found. Please install bash or zsh and ensure $SHELL is set. "
|
|
123
|
+
"Tried bash/zsh in PATH and common locations."
|
|
124
|
+
)
|
|
125
|
+
logger.error(error_message)
|
|
126
|
+
raise RuntimeError(error_message)
|
|
127
|
+
|
|
128
|
+
git_bash = _find_git_bash_windows()
|
|
129
|
+
if git_bash:
|
|
130
|
+
logger.debug("Using Git Bash: %s", git_bash)
|
|
131
|
+
return git_bash
|
|
132
|
+
|
|
133
|
+
cmd_path = _windows_cmd_path()
|
|
134
|
+
if cmd_path:
|
|
135
|
+
logger.warning("Falling back to cmd.exe; bash not found. Using: %s", cmd_path)
|
|
136
|
+
return cmd_path
|
|
137
|
+
|
|
138
|
+
error_message = (
|
|
139
|
+
"No suitable shell found on Windows. Install Git for Windows to provide bash "
|
|
140
|
+
"or ensure cmd.exe is available."
|
|
141
|
+
)
|
|
142
|
+
logger.error(error_message)
|
|
143
|
+
raise RuntimeError(error_message)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def build_shell_command(shell_path: str, command: str) -> List[str]:
|
|
147
|
+
"""Build argv for running a command with the selected shell.
|
|
148
|
+
|
|
149
|
+
For bash/zsh (including Git Bash), use -lc to run as login shell.
|
|
150
|
+
For cmd.exe fallback, use /d /s /c.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
lower = shell_path.lower()
|
|
154
|
+
if lower.endswith("cmd.exe") or lower.endswith("\\cmd"):
|
|
155
|
+
return [shell_path, "/d", "/s", "/c", command]
|
|
156
|
+
return [shell_path, "-lc", command]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
__all__ = ["find_suitable_shell", "build_shell_command"]
|
ripperdoc/utils/todo.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Todo storage and utilities for Ripperdoc.
|
|
2
|
+
|
|
3
|
+
This module provides simple, file-based todo management so tools can
|
|
4
|
+
persist and query tasks between turns. Todos are stored under the user's
|
|
5
|
+
home directory at `~/.ripperdoc/todos/<project>/todos.json`, where
|
|
6
|
+
`<project>` is a sanitized form of the project path.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import List, Literal, Optional, Sequence, Tuple
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
16
|
+
|
|
17
|
+
from ripperdoc.utils.log import get_logger
|
|
18
|
+
from ripperdoc.utils.path_utils import project_storage_dir
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
logger = get_logger()
|
|
22
|
+
|
|
23
|
+
TodoStatus = Literal["pending", "in_progress", "completed"]
|
|
24
|
+
TodoPriority = Literal["high", "medium", "low"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TodoItem(BaseModel):
|
|
28
|
+
"""Represents a single todo entry."""
|
|
29
|
+
|
|
30
|
+
id: str = Field(description="Unique identifier for the todo item")
|
|
31
|
+
content: str = Field(description="Task description")
|
|
32
|
+
status: TodoStatus = Field(
|
|
33
|
+
default="pending", description="Current state: pending, in_progress, completed"
|
|
34
|
+
)
|
|
35
|
+
priority: TodoPriority = Field(default="medium", description="Priority: high|medium|low")
|
|
36
|
+
created_at: Optional[float] = Field(default=None, description="Unix timestamp when created")
|
|
37
|
+
updated_at: Optional[float] = Field(default=None, description="Unix timestamp when updated")
|
|
38
|
+
previous_status: Optional[TodoStatus] = Field(
|
|
39
|
+
default=None, description="Previous status, used for audits"
|
|
40
|
+
)
|
|
41
|
+
model_config = ConfigDict(extra="ignore")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
MAX_TODOS = 200
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _storage_path(project_root: Optional[Path], ensure_dir: bool) -> Path:
|
|
48
|
+
"""Return the todo storage path, optionally ensuring the directory exists."""
|
|
49
|
+
root = project_root or Path.cwd()
|
|
50
|
+
base_dir = Path.home() / ".ripperdoc" / "todos"
|
|
51
|
+
storage_dir = project_storage_dir(base_dir, root, ensure=ensure_dir)
|
|
52
|
+
return storage_dir / "todos.json"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def validate_todos(
|
|
56
|
+
todos: Sequence[TodoItem], max_items: int = MAX_TODOS
|
|
57
|
+
) -> Tuple[bool, str | None]:
|
|
58
|
+
"""Basic validation for a todo list."""
|
|
59
|
+
if len(todos) > max_items:
|
|
60
|
+
return False, f"Too many todos; limit is {max_items}."
|
|
61
|
+
|
|
62
|
+
ids = [todo.id for todo in todos]
|
|
63
|
+
duplicate_ids = {id_ for id_ in ids if ids.count(id_) > 1}
|
|
64
|
+
if duplicate_ids:
|
|
65
|
+
return False, f"Duplicate todo IDs found: {sorted(duplicate_ids)}"
|
|
66
|
+
|
|
67
|
+
in_progress = [todo for todo in todos if todo.status == "in_progress"]
|
|
68
|
+
if len(in_progress) > 1:
|
|
69
|
+
return False, "Only one todo can be marked in_progress at a time."
|
|
70
|
+
|
|
71
|
+
empty_contents = [todo.id for todo in todos if not todo.content.strip()]
|
|
72
|
+
if empty_contents:
|
|
73
|
+
return False, f"Todos require content. Empty content for IDs: {sorted(empty_contents)}"
|
|
74
|
+
|
|
75
|
+
return True, None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_todos(project_root: Optional[Path] = None) -> List[TodoItem]:
|
|
79
|
+
"""Load todos from disk."""
|
|
80
|
+
path = _storage_path(project_root, ensure_dir=False)
|
|
81
|
+
if not path.exists():
|
|
82
|
+
return []
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
raw = json.loads(path.read_text())
|
|
86
|
+
except (json.JSONDecodeError, OSError, IOError, UnicodeDecodeError) as exc:
|
|
87
|
+
logger.warning(
|
|
88
|
+
"Failed to load todos from disk: %s: %s",
|
|
89
|
+
type(exc).__name__, exc,
|
|
90
|
+
extra={"path": str(path)},
|
|
91
|
+
)
|
|
92
|
+
return []
|
|
93
|
+
|
|
94
|
+
todos: List[TodoItem] = []
|
|
95
|
+
for item in raw:
|
|
96
|
+
try:
|
|
97
|
+
todos.append(TodoItem(**item))
|
|
98
|
+
except ValidationError as exc:
|
|
99
|
+
logger.error(f"Failed to parse todo item: {exc}")
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
# Preserve stored order; do not reorder based on status/priority.
|
|
103
|
+
return todos
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def save_todos(todos: Sequence[TodoItem], project_root: Optional[Path] = None) -> None:
|
|
107
|
+
"""Persist todos to disk."""
|
|
108
|
+
path = _storage_path(project_root, ensure_dir=True)
|
|
109
|
+
path.write_text(json.dumps([todo.model_dump() for todo in todos], indent=2))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def set_todos(
|
|
113
|
+
todos: Sequence[TodoItem],
|
|
114
|
+
project_root: Optional[Path] = None,
|
|
115
|
+
) -> List[TodoItem]:
|
|
116
|
+
"""Validate, normalize, and persist the provided todos."""
|
|
117
|
+
ok, message = validate_todos(todos)
|
|
118
|
+
if not ok:
|
|
119
|
+
raise ValueError(message or "Invalid todos.")
|
|
120
|
+
|
|
121
|
+
existing = {todo.id: todo for todo in load_todos(project_root)}
|
|
122
|
+
now = time.time()
|
|
123
|
+
|
|
124
|
+
normalized: List[TodoItem] = []
|
|
125
|
+
for todo in todos:
|
|
126
|
+
previous = existing.get(todo.id)
|
|
127
|
+
normalized.append(
|
|
128
|
+
todo.model_copy(
|
|
129
|
+
update={
|
|
130
|
+
"created_at": previous.created_at if previous else todo.created_at or now,
|
|
131
|
+
"updated_at": now,
|
|
132
|
+
"previous_status": (
|
|
133
|
+
previous.status
|
|
134
|
+
if previous and previous.status != todo.status
|
|
135
|
+
else todo.previous_status
|
|
136
|
+
),
|
|
137
|
+
}
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Keep the caller-provided order; do not resort.
|
|
142
|
+
save_todos(normalized, project_root)
|
|
143
|
+
return list(normalized)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def clear_todos(project_root: Optional[Path] = None) -> None:
|
|
147
|
+
"""Remove all todos."""
|
|
148
|
+
save_todos([], project_root)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def get_next_actionable(todos: Sequence[TodoItem]) -> Optional[TodoItem]:
|
|
152
|
+
"""Return the next todo to work on (in_progress first, then pending)."""
|
|
153
|
+
for status in ("in_progress", "pending"):
|
|
154
|
+
for todo in todos:
|
|
155
|
+
if todo.status == status:
|
|
156
|
+
return todo
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def summarize_todos(todos: Sequence[TodoItem]) -> dict:
|
|
161
|
+
"""Return simple statistics for a todo collection."""
|
|
162
|
+
return {
|
|
163
|
+
"total": len(todos),
|
|
164
|
+
"by_status": {
|
|
165
|
+
"pending": len([t for t in todos if t.status == "pending"]),
|
|
166
|
+
"in_progress": len([t for t in todos if t.status == "in_progress"]),
|
|
167
|
+
"completed": len([t for t in todos if t.status == "completed"]),
|
|
168
|
+
},
|
|
169
|
+
"by_priority": {
|
|
170
|
+
"high": len([t for t in todos if t.priority == "high"]),
|
|
171
|
+
"medium": len([t for t in todos if t.priority == "medium"]),
|
|
172
|
+
"low": len([t for t in todos if t.priority == "low"]),
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def format_todo_summary(todos: Sequence[TodoItem]) -> str:
|
|
178
|
+
"""Create a concise summary string for use in tool outputs."""
|
|
179
|
+
stats = summarize_todos(todos)
|
|
180
|
+
summary = (
|
|
181
|
+
f"Todos updated (total {stats['total']}; "
|
|
182
|
+
f"{stats['by_status']['pending']} pending, "
|
|
183
|
+
f"{stats['by_status']['in_progress']} in progress, "
|
|
184
|
+
f"{stats['by_status']['completed']} completed)."
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
next_item = get_next_actionable(todos)
|
|
188
|
+
if next_item:
|
|
189
|
+
summary += f" Next to tackle: {next_item.content} (id: {next_item.id}, status: {next_item.status})."
|
|
190
|
+
elif stats["total"] == 0:
|
|
191
|
+
summary += " No todos stored yet."
|
|
192
|
+
|
|
193
|
+
return summary
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def format_todo_lines(todos: Sequence[TodoItem]) -> List[str]:
|
|
197
|
+
"""Return human-readable todo lines."""
|
|
198
|
+
status_marker = {
|
|
199
|
+
"completed": "●",
|
|
200
|
+
"in_progress": "◐",
|
|
201
|
+
"pending": "○",
|
|
202
|
+
}
|
|
203
|
+
return [f"{status_marker.get(todo.status, '○')} {todo.content}" for todo in todos]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Shared token estimation utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
from ripperdoc.utils.log import get_logger
|
|
8
|
+
|
|
9
|
+
logger = get_logger()
|
|
10
|
+
|
|
11
|
+
# Optional: use tiktoken for accurate counts when available.
|
|
12
|
+
_TIKTOKEN_ENCODING: tiktoken.Encoding | None = None
|
|
13
|
+
try: # pragma: no cover - optional dependency
|
|
14
|
+
import tiktoken # type: ignore
|
|
15
|
+
|
|
16
|
+
_TIKTOKEN_ENCODING = tiktoken.get_encoding("cl100k_base")
|
|
17
|
+
except (ImportError, ModuleNotFoundError, OSError, RuntimeError): # pragma: no cover - runtime fallback
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def estimate_tokens(text: str) -> int:
|
|
22
|
+
"""Estimate token count, preferring tiktoken when available."""
|
|
23
|
+
if not text:
|
|
24
|
+
return 0
|
|
25
|
+
if _TIKTOKEN_ENCODING:
|
|
26
|
+
try:
|
|
27
|
+
return len(_TIKTOKEN_ENCODING.encode(text))
|
|
28
|
+
except (UnicodeDecodeError, ValueError, RuntimeError):
|
|
29
|
+
logger.debug("[token_estimation] tiktoken encode failed; falling back to heuristic")
|
|
30
|
+
# Heuristic: ~4 characters per token
|
|
31
|
+
return max(1, math.ceil(len(text) / 4))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
__all__ = ["estimate_tokens"]
|