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.
Files changed (107) hide show
  1. ripperdoc/__init__.py +3 -0
  2. ripperdoc/__main__.py +20 -0
  3. ripperdoc/cli/__init__.py +1 -0
  4. ripperdoc/cli/cli.py +405 -0
  5. ripperdoc/cli/commands/__init__.py +82 -0
  6. ripperdoc/cli/commands/agents_cmd.py +263 -0
  7. ripperdoc/cli/commands/base.py +19 -0
  8. ripperdoc/cli/commands/clear_cmd.py +18 -0
  9. ripperdoc/cli/commands/compact_cmd.py +23 -0
  10. ripperdoc/cli/commands/config_cmd.py +31 -0
  11. ripperdoc/cli/commands/context_cmd.py +144 -0
  12. ripperdoc/cli/commands/cost_cmd.py +82 -0
  13. ripperdoc/cli/commands/doctor_cmd.py +221 -0
  14. ripperdoc/cli/commands/exit_cmd.py +19 -0
  15. ripperdoc/cli/commands/help_cmd.py +20 -0
  16. ripperdoc/cli/commands/mcp_cmd.py +70 -0
  17. ripperdoc/cli/commands/memory_cmd.py +202 -0
  18. ripperdoc/cli/commands/models_cmd.py +413 -0
  19. ripperdoc/cli/commands/permissions_cmd.py +302 -0
  20. ripperdoc/cli/commands/resume_cmd.py +98 -0
  21. ripperdoc/cli/commands/status_cmd.py +167 -0
  22. ripperdoc/cli/commands/tasks_cmd.py +278 -0
  23. ripperdoc/cli/commands/todos_cmd.py +69 -0
  24. ripperdoc/cli/commands/tools_cmd.py +19 -0
  25. ripperdoc/cli/ui/__init__.py +1 -0
  26. ripperdoc/cli/ui/context_display.py +298 -0
  27. ripperdoc/cli/ui/helpers.py +22 -0
  28. ripperdoc/cli/ui/rich_ui.py +1557 -0
  29. ripperdoc/cli/ui/spinner.py +49 -0
  30. ripperdoc/cli/ui/thinking_spinner.py +128 -0
  31. ripperdoc/cli/ui/tool_renderers.py +298 -0
  32. ripperdoc/core/__init__.py +1 -0
  33. ripperdoc/core/agents.py +486 -0
  34. ripperdoc/core/commands.py +33 -0
  35. ripperdoc/core/config.py +559 -0
  36. ripperdoc/core/default_tools.py +88 -0
  37. ripperdoc/core/permissions.py +252 -0
  38. ripperdoc/core/providers/__init__.py +47 -0
  39. ripperdoc/core/providers/anthropic.py +250 -0
  40. ripperdoc/core/providers/base.py +265 -0
  41. ripperdoc/core/providers/gemini.py +615 -0
  42. ripperdoc/core/providers/openai.py +487 -0
  43. ripperdoc/core/query.py +1058 -0
  44. ripperdoc/core/query_utils.py +622 -0
  45. ripperdoc/core/skills.py +295 -0
  46. ripperdoc/core/system_prompt.py +431 -0
  47. ripperdoc/core/tool.py +240 -0
  48. ripperdoc/sdk/__init__.py +9 -0
  49. ripperdoc/sdk/client.py +333 -0
  50. ripperdoc/tools/__init__.py +1 -0
  51. ripperdoc/tools/ask_user_question_tool.py +431 -0
  52. ripperdoc/tools/background_shell.py +389 -0
  53. ripperdoc/tools/bash_output_tool.py +98 -0
  54. ripperdoc/tools/bash_tool.py +1016 -0
  55. ripperdoc/tools/dynamic_mcp_tool.py +428 -0
  56. ripperdoc/tools/enter_plan_mode_tool.py +226 -0
  57. ripperdoc/tools/exit_plan_mode_tool.py +153 -0
  58. ripperdoc/tools/file_edit_tool.py +346 -0
  59. ripperdoc/tools/file_read_tool.py +203 -0
  60. ripperdoc/tools/file_write_tool.py +205 -0
  61. ripperdoc/tools/glob_tool.py +179 -0
  62. ripperdoc/tools/grep_tool.py +370 -0
  63. ripperdoc/tools/kill_bash_tool.py +136 -0
  64. ripperdoc/tools/ls_tool.py +471 -0
  65. ripperdoc/tools/mcp_tools.py +591 -0
  66. ripperdoc/tools/multi_edit_tool.py +456 -0
  67. ripperdoc/tools/notebook_edit_tool.py +386 -0
  68. ripperdoc/tools/skill_tool.py +205 -0
  69. ripperdoc/tools/task_tool.py +379 -0
  70. ripperdoc/tools/todo_tool.py +494 -0
  71. ripperdoc/tools/tool_search_tool.py +380 -0
  72. ripperdoc/utils/__init__.py +1 -0
  73. ripperdoc/utils/bash_constants.py +51 -0
  74. ripperdoc/utils/bash_output_utils.py +43 -0
  75. ripperdoc/utils/coerce.py +34 -0
  76. ripperdoc/utils/context_length_errors.py +252 -0
  77. ripperdoc/utils/exit_code_handlers.py +241 -0
  78. ripperdoc/utils/file_watch.py +135 -0
  79. ripperdoc/utils/git_utils.py +274 -0
  80. ripperdoc/utils/json_utils.py +27 -0
  81. ripperdoc/utils/log.py +176 -0
  82. ripperdoc/utils/mcp.py +560 -0
  83. ripperdoc/utils/memory.py +253 -0
  84. ripperdoc/utils/message_compaction.py +676 -0
  85. ripperdoc/utils/messages.py +519 -0
  86. ripperdoc/utils/output_utils.py +258 -0
  87. ripperdoc/utils/path_ignore.py +677 -0
  88. ripperdoc/utils/path_utils.py +46 -0
  89. ripperdoc/utils/permissions/__init__.py +27 -0
  90. ripperdoc/utils/permissions/path_validation_utils.py +174 -0
  91. ripperdoc/utils/permissions/shell_command_validation.py +552 -0
  92. ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
  93. ripperdoc/utils/prompt.py +17 -0
  94. ripperdoc/utils/safe_get_cwd.py +31 -0
  95. ripperdoc/utils/sandbox_utils.py +38 -0
  96. ripperdoc/utils/session_history.py +260 -0
  97. ripperdoc/utils/session_usage.py +117 -0
  98. ripperdoc/utils/shell_token_utils.py +95 -0
  99. ripperdoc/utils/shell_utils.py +159 -0
  100. ripperdoc/utils/todo.py +203 -0
  101. ripperdoc/utils/token_estimation.py +34 -0
  102. ripperdoc-0.2.6.dist-info/METADATA +193 -0
  103. ripperdoc-0.2.6.dist-info/RECORD +107 -0
  104. ripperdoc-0.2.6.dist-info/WHEEL +5 -0
  105. ripperdoc-0.2.6.dist-info/entry_points.txt +3 -0
  106. ripperdoc-0.2.6.dist-info/licenses/LICENSE +53 -0
  107. 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"]
@@ -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"]