ripperdoc 0.1.0__py3-none-any.whl → 0.2.2__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 (57) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +75 -15
  3. ripperdoc/cli/commands/__init__.py +4 -0
  4. ripperdoc/cli/commands/agents_cmd.py +23 -1
  5. ripperdoc/cli/commands/context_cmd.py +13 -3
  6. ripperdoc/cli/commands/cost_cmd.py +1 -1
  7. ripperdoc/cli/commands/doctor_cmd.py +200 -0
  8. ripperdoc/cli/commands/memory_cmd.py +209 -0
  9. ripperdoc/cli/commands/models_cmd.py +25 -0
  10. ripperdoc/cli/commands/resume_cmd.py +3 -3
  11. ripperdoc/cli/commands/status_cmd.py +5 -5
  12. ripperdoc/cli/commands/tasks_cmd.py +32 -5
  13. ripperdoc/cli/ui/context_display.py +4 -3
  14. ripperdoc/cli/ui/rich_ui.py +205 -43
  15. ripperdoc/cli/ui/spinner.py +3 -4
  16. ripperdoc/core/agents.py +10 -6
  17. ripperdoc/core/config.py +48 -3
  18. ripperdoc/core/default_tools.py +26 -6
  19. ripperdoc/core/permissions.py +19 -0
  20. ripperdoc/core/query.py +238 -302
  21. ripperdoc/core/query_utils.py +537 -0
  22. ripperdoc/core/system_prompt.py +2 -1
  23. ripperdoc/core/tool.py +14 -1
  24. ripperdoc/sdk/client.py +1 -1
  25. ripperdoc/tools/background_shell.py +9 -3
  26. ripperdoc/tools/bash_tool.py +19 -4
  27. ripperdoc/tools/file_edit_tool.py +9 -2
  28. ripperdoc/tools/file_read_tool.py +9 -2
  29. ripperdoc/tools/file_write_tool.py +15 -2
  30. ripperdoc/tools/glob_tool.py +57 -17
  31. ripperdoc/tools/grep_tool.py +9 -2
  32. ripperdoc/tools/ls_tool.py +244 -75
  33. ripperdoc/tools/mcp_tools.py +47 -19
  34. ripperdoc/tools/multi_edit_tool.py +13 -2
  35. ripperdoc/tools/notebook_edit_tool.py +9 -6
  36. ripperdoc/tools/task_tool.py +20 -5
  37. ripperdoc/tools/todo_tool.py +163 -29
  38. ripperdoc/tools/tool_search_tool.py +15 -4
  39. ripperdoc/utils/git_utils.py +276 -0
  40. ripperdoc/utils/json_utils.py +28 -0
  41. ripperdoc/utils/log.py +130 -29
  42. ripperdoc/utils/mcp.py +83 -10
  43. ripperdoc/utils/memory.py +14 -1
  44. ripperdoc/utils/message_compaction.py +51 -14
  45. ripperdoc/utils/messages.py +63 -4
  46. ripperdoc/utils/output_utils.py +36 -9
  47. ripperdoc/utils/permissions/path_validation_utils.py +6 -0
  48. ripperdoc/utils/safe_get_cwd.py +4 -0
  49. ripperdoc/utils/session_history.py +27 -9
  50. ripperdoc/utils/todo.py +2 -2
  51. {ripperdoc-0.1.0.dist-info → ripperdoc-0.2.2.dist-info}/METADATA +4 -2
  52. ripperdoc-0.2.2.dist-info/RECORD +86 -0
  53. ripperdoc-0.1.0.dist-info/RECORD +0 -81
  54. {ripperdoc-0.1.0.dist-info → ripperdoc-0.2.2.dist-info}/WHEEL +0 -0
  55. {ripperdoc-0.1.0.dist-info → ripperdoc-0.2.2.dist-info}/entry_points.txt +0 -0
  56. {ripperdoc-0.1.0.dist-info → ripperdoc-0.2.2.dist-info}/licenses/LICENSE +0 -0
  57. {ripperdoc-0.1.0.dist-info → ripperdoc-0.2.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,209 @@
1
+ """Slash command to view and edit AGENTS memory files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shlex
7
+ import shutil
8
+ import subprocess
9
+ from pathlib import Path
10
+ from typing import Any, List, Optional
11
+
12
+ from rich.markup import escape
13
+ from rich.panel import Panel
14
+ from rich.table import Table
15
+
16
+ from ripperdoc.utils.memory import (
17
+ LOCAL_MEMORY_FILE_NAME,
18
+ MEMORY_FILE_NAME,
19
+ collect_all_memory_files,
20
+ )
21
+
22
+ from .base import SlashCommand
23
+
24
+
25
+ def _shorten_path(path: Path, project_path: Path) -> str:
26
+ """Return a short, user-friendly path."""
27
+ try:
28
+ return str(path.resolve().relative_to(project_path.resolve()))
29
+ except Exception:
30
+ pass
31
+
32
+ home = Path.home()
33
+ try:
34
+ rel_home = path.resolve().relative_to(home)
35
+ return f"~/{rel_home}"
36
+ except Exception:
37
+ return str(path)
38
+
39
+
40
+ def _preferred_user_memory_path() -> Path:
41
+ """Pick the user-level memory path, preferring ~/.ripperdoc/AGENTS.md."""
42
+ home = Path.home()
43
+ preferred_dir = home / ".ripperdoc"
44
+ preferred_path = preferred_dir / MEMORY_FILE_NAME
45
+ fallback_path = home / MEMORY_FILE_NAME
46
+
47
+ if preferred_path.exists():
48
+ return preferred_path
49
+ if fallback_path.exists():
50
+ return fallback_path
51
+
52
+ preferred_dir.mkdir(parents=True, exist_ok=True)
53
+ return preferred_path
54
+
55
+
56
+ def _ensure_file(path: Path) -> bool:
57
+ """Ensure the target file exists. Returns True if created."""
58
+ path.parent.mkdir(parents=True, exist_ok=True)
59
+ if path.exists():
60
+ return False
61
+ path.write_text("", encoding="utf-8")
62
+ return True
63
+
64
+
65
+ def _ensure_gitignore_entry(project_path: Path, entry: str) -> bool:
66
+ """Ensure an entry exists in .gitignore. Returns True if added."""
67
+ gitignore_path = project_path / ".gitignore"
68
+ try:
69
+ text = ""
70
+ if gitignore_path.exists():
71
+ text = gitignore_path.read_text(encoding="utf-8", errors="ignore")
72
+ existing_lines = text.splitlines()
73
+ if entry in existing_lines:
74
+ return False
75
+ with gitignore_path.open("a", encoding="utf-8") as f:
76
+ if text and not text.endswith("\n"):
77
+ f.write("\n")
78
+ f.write(f"{entry}\n")
79
+ return True
80
+ except Exception:
81
+ return False
82
+
83
+
84
+ def _determine_editor_command() -> Optional[List[str]]:
85
+ """Resolve the editor command from environment or common defaults."""
86
+ for env_var in ("VISUAL", "EDITOR"):
87
+ value = os.environ.get(env_var)
88
+ if value:
89
+ return shlex.split(value)
90
+
91
+ candidates = ["code", "nano", "vim", "vi"]
92
+ if os.name == "nt":
93
+ candidates.insert(0, "notepad")
94
+
95
+ for candidate in candidates:
96
+ if shutil.which(candidate):
97
+ return [candidate]
98
+ return None
99
+
100
+
101
+ def _open_in_editor(path: Path, console: Any) -> bool:
102
+ """Open the file in a text editor; returns True if an editor was launched."""
103
+ editor_cmd = _determine_editor_command()
104
+ if not editor_cmd:
105
+ console.print(
106
+ f"[yellow]No editor configured. Set $EDITOR or $VISUAL, or manually edit: {escape(str(path))}[/yellow]"
107
+ )
108
+ return False
109
+
110
+ cmd = [*editor_cmd, str(path)]
111
+ try:
112
+ console.print(f"[dim]Opening with: {' '.join(editor_cmd)}[/dim]")
113
+ subprocess.run(cmd, check=False)
114
+ return True
115
+ except FileNotFoundError:
116
+ console.print(f"[red]Editor command not found: {escape(editor_cmd[0])}[/red]")
117
+ return False
118
+ except Exception as exc: # pragma: no cover - best-effort logging
119
+ console.print(f"[red]Failed to launch editor: {escape(str(exc))}[/red]")
120
+ return False
121
+
122
+
123
+ def _render_memory_table(console: Any, project_path: Path) -> None:
124
+ files = collect_all_memory_files()
125
+ table = Table(title="Memory files", show_header=True, header_style="bold cyan")
126
+ table.add_column("Type", style="bold")
127
+ table.add_column("Location")
128
+ table.add_column("Nested", justify="center")
129
+
130
+ for memory_file in files:
131
+ display_path = _shorten_path(Path(memory_file.path), project_path)
132
+ nested = "yes" if getattr(memory_file, "is_nested", False) else ""
133
+ table.add_row(memory_file.type, escape(display_path), nested)
134
+
135
+ if files:
136
+ console.print(table)
137
+ else:
138
+ console.print("[yellow]No memory files found yet.[/yellow]")
139
+
140
+
141
+ def _handle(ui: Any, trimmed_arg: str) -> bool:
142
+ project_path = getattr(ui, "project_path", Path.cwd())
143
+ scope = trimmed_arg.strip().lower()
144
+
145
+ if scope:
146
+ scope_aliases = {
147
+ "project": "project",
148
+ "workspace": "project",
149
+ "local": "local",
150
+ "private": "local",
151
+ "user": "user",
152
+ "global": "user",
153
+ }
154
+ if scope not in scope_aliases:
155
+ ui.console.print(
156
+ "[red]Unknown scope. Use one of: project, local, user.[/red]"
157
+ )
158
+ return True
159
+
160
+ resolved_scope = scope_aliases[scope]
161
+ if resolved_scope == "project":
162
+ target_path = project_path / MEMORY_FILE_NAME
163
+ heading = "Project memory (checked in)"
164
+ elif resolved_scope == "local":
165
+ target_path = project_path / LOCAL_MEMORY_FILE_NAME
166
+ heading = "Local memory (not checked in)"
167
+ else:
168
+ target_path = _preferred_user_memory_path()
169
+ heading = "User memory (home directory)"
170
+
171
+ created = _ensure_file(target_path)
172
+ gitignore_added = False
173
+ if resolved_scope == "local":
174
+ gitignore_added = _ensure_gitignore_entry(project_path, LOCAL_MEMORY_FILE_NAME)
175
+
176
+ _open_in_editor(target_path, ui.console)
177
+
178
+ messages: List[str] = [
179
+ f"{heading}: {escape(_shorten_path(target_path, project_path))}"
180
+ ]
181
+ if created:
182
+ messages.append("Created new memory file.")
183
+ if gitignore_added:
184
+ messages.append("Added AGENTS.local.md to .gitignore.")
185
+ if not created:
186
+ messages.append("Opened existing memory file.")
187
+
188
+ ui.console.print(Panel("\n".join(messages), title="/memory"))
189
+ return True
190
+
191
+ _render_memory_table(ui.console, project_path)
192
+ ui.console.print(
193
+ "[dim]Usage: /memory project | /memory local | /memory user[/dim]"
194
+ )
195
+ ui.console.print(
196
+ "[dim]Project and user memories feed directly into the system prompt.[/dim]"
197
+ )
198
+ return True
199
+
200
+
201
+ command = SlashCommand(
202
+ name="memory",
203
+ description="List and edit AGENTS memory files",
204
+ handler=_handle,
205
+ aliases=("mem",),
206
+ )
207
+
208
+
209
+ __all__ = ["command"]
@@ -13,15 +13,22 @@ from ripperdoc.core.config import (
13
13
  get_global_config,
14
14
  set_model_pointer,
15
15
  )
16
+ from ripperdoc.utils.log import get_logger
16
17
 
17
18
  from .base import SlashCommand
18
19
 
20
+ logger = get_logger()
21
+
19
22
 
20
23
  def _handle(ui: Any, trimmed_arg: str) -> bool:
21
24
  console = ui.console
22
25
  tokens = trimmed_arg.split()
23
26
  subcmd = tokens[0].lower() if tokens else ""
24
27
  config = get_global_config()
28
+ logger.info(
29
+ "[models_cmd] Handling /models command",
30
+ extra={"subcommand": subcmd or "list", "session_id": getattr(ui, "session_id", None)},
31
+ )
25
32
 
26
33
  def print_models_usage() -> None:
27
34
  console.print("[bold]/models[/bold] — list configured models")
@@ -167,6 +174,10 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
167
174
  )
168
175
  except Exception as exc:
169
176
  console.print(f"[red]Failed to save model: {escape(str(exc))}[/red]")
177
+ logger.exception(
178
+ "[models_cmd] Failed to save model profile",
179
+ extra={"profile": profile_name, "session_id": getattr(ui, "session_id", None)},
180
+ )
170
181
  return True
171
182
 
172
183
  marker = " (main)" if set_as_main else ""
@@ -255,6 +266,10 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
255
266
  )
256
267
  except Exception as exc:
257
268
  console.print(f"[red]Failed to update model: {escape(str(exc))}[/red]")
269
+ logger.exception(
270
+ "[models_cmd] Failed to update model profile",
271
+ extra={"profile": profile_name, "session_id": getattr(ui, "session_id", None)},
272
+ )
258
273
  return True
259
274
 
260
275
  console.print(f"[green]✓ Model '{escape(profile_name)}' updated[/green]")
@@ -274,6 +289,10 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
274
289
  except Exception as exc:
275
290
  console.print(f"[red]Failed to delete model: {escape(str(exc))}[/red]")
276
291
  print_models_usage()
292
+ logger.exception(
293
+ "[models_cmd] Failed to delete model profile",
294
+ extra={"profile": target, "session_id": getattr(ui, "session_id", None)},
295
+ )
277
296
  return True
278
297
 
279
298
  if subcmd in ("use", "main", "set-main"):
@@ -288,6 +307,10 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
288
307
  except Exception as exc:
289
308
  console.print(f"[red]{escape(str(exc))}[/red]")
290
309
  print_models_usage()
310
+ logger.exception(
311
+ "[models_cmd] Failed to set main model pointer",
312
+ extra={"profile": target, "session_id": getattr(ui, "session_id", None)},
313
+ )
291
314
  return True
292
315
 
293
316
  print_models_usage()
@@ -312,6 +335,8 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
312
335
  markup=False,
313
336
  )
314
337
  console.print(f" api_key: {'***' if profile.api_key else 'Not set'}", markup=False)
338
+ if profile.openai_tool_mode:
339
+ console.print(f" openai_tool_mode: {profile.openai_tool_mode}", markup=False)
315
340
  pointer_labels = ", ".join(f"{p}->{v or '-'}" for p, v in pointer_map.items())
316
341
  console.print(f"[dim]Pointers: {escape(pointer_labels)}[/dim]")
317
342
  return True
@@ -17,7 +17,7 @@ def _format_time(dt: datetime) -> str:
17
17
  return dt.strftime("%Y-%m-%d %H:%M")
18
18
 
19
19
 
20
- def _choose_session(ui, arg: str) -> Optional[SessionSummary]:
20
+ def _choose_session(ui: Any, arg: str) -> Optional[SessionSummary]:
21
21
  sessions = list_session_summaries(ui.project_path)
22
22
  if not sessions:
23
23
  ui.console.print("[yellow]No saved sessions found for this project.[/yellow]")
@@ -30,7 +30,7 @@ def _choose_session(ui, arg: str) -> Optional[SessionSummary]:
30
30
  if 0 <= idx < len(sessions):
31
31
  return sessions[idx]
32
32
  ui.console.print(
33
- f"[red]Invalid session index {escape(str(idx))}. Choose 0-{len(sessions)-1}.[/red]"
33
+ f"[red]Invalid session index {escape(str(idx))}. Choose 0-{len(sessions) - 1}.[/red]"
34
34
  )
35
35
  else:
36
36
  # Treat arg as session id if it matches.
@@ -60,7 +60,7 @@ def _choose_session(ui, arg: str) -> Optional[SessionSummary]:
60
60
  idx = int(choice_text)
61
61
  if idx < 0 or idx >= len(sessions):
62
62
  ui.console.print(
63
- f"[red]Invalid session index {escape(str(idx))}. Choose 0-{len(sessions)-1}.[/red]"
63
+ f"[red]Invalid session index {escape(str(idx))}. Choose 0-{len(sessions) - 1}.[/red]"
64
64
  )
65
65
  return None
66
66
  return sessions[idx]
@@ -22,8 +22,8 @@ def _auth_token_display(profile: Optional[ModelProfile]) -> Tuple[str, Optional[
22
22
  if not profile:
23
23
  return ("Not configured", None)
24
24
 
25
- provider_value = profile.provider.value if hasattr(profile.provider, "value") else str(
26
- profile.provider
25
+ provider_value = (
26
+ profile.provider.value if hasattr(profile.provider, "value") else str(profile.provider)
27
27
  )
28
28
 
29
29
  env_candidates = api_key_env_candidates(profile.provider)
@@ -50,8 +50,8 @@ def _api_base_display(profile: Optional[ModelProfile]) -> str:
50
50
  ProviderType.GEMINI: "Gemini base URL",
51
51
  }
52
52
  label = label_map.get(profile.provider, "API base URL")
53
- provider_value = profile.provider.value if hasattr(profile.provider, "value") else str(
54
- profile.provider
53
+ provider_value = (
54
+ profile.provider.value if hasattr(profile.provider, "value") else str(profile.provider)
55
55
  )
56
56
 
57
57
  env_candidates = api_base_env_candidates(profile.provider)
@@ -81,7 +81,7 @@ def _memory_status_lines(memory_files: List[MemoryFile]) -> List[str]:
81
81
 
82
82
 
83
83
  def _setting_sources_summary(
84
- config,
84
+ config: Any,
85
85
  profile: Optional[ModelProfile],
86
86
  memory_files: List[MemoryFile],
87
87
  auth_env_var: Optional[str],
@@ -13,12 +13,16 @@ from ripperdoc.tools.background_shell import (
13
13
  kill_background_task,
14
14
  list_background_tasks,
15
15
  )
16
+ from ripperdoc.utils.log import get_logger
16
17
 
17
- from typing import Any
18
+ from typing import Any, Optional
18
19
  from .base import SlashCommand
19
20
 
20
21
 
21
- def _format_duration(duration_ms) -> str:
22
+ logger = get_logger()
23
+
24
+
25
+ def _format_duration(duration_ms: Optional[float]) -> str:
22
26
  """Render milliseconds into a short human-readable duration."""
23
27
  if duration_ms is None:
24
28
  return "-"
@@ -82,7 +86,7 @@ def _tail_lines(text: str, max_lines: int = 20, max_chars: int = 4000) -> str:
82
86
  return content
83
87
 
84
88
 
85
- def _list_tasks(ui) -> bool:
89
+ def _list_tasks(ui: Any) -> bool:
86
90
  console = ui.console
87
91
  task_ids = list_background_tasks()
88
92
 
@@ -103,6 +107,10 @@ def _list_tasks(ui) -> bool:
103
107
  status = get_background_status(task_id, consume=False)
104
108
  except Exception as exc:
105
109
  table.add_row(escape(task_id), "[red]error[/]", escape(str(exc)), "-")
110
+ logger.exception(
111
+ "[tasks_cmd] Failed to read background task status",
112
+ extra={"task_id": task_id, "session_id": getattr(ui, "session_id", None)},
113
+ )
106
114
  continue
107
115
 
108
116
  command = status.get("command") or ""
@@ -128,7 +136,7 @@ def _list_tasks(ui) -> bool:
128
136
  return True
129
137
 
130
138
 
131
- def _kill_task(ui, task_id: str) -> bool:
139
+ def _kill_task(ui: Any, task_id: str) -> bool:
132
140
  console = ui.console
133
141
  try:
134
142
  status = get_background_status(task_id, consume=False)
@@ -137,6 +145,10 @@ def _kill_task(ui, task_id: str) -> bool:
137
145
  return True
138
146
  except Exception as exc:
139
147
  console.print(f"[red]Failed to read task '{escape(task_id)}': {escape(str(exc))}[/red]")
148
+ logger.exception(
149
+ "[tasks_cmd] Failed to read task before kill",
150
+ extra={"task_id": task_id, "session_id": getattr(ui, "session_id", None)},
151
+ )
140
152
  return True
141
153
 
142
154
  if status.get("status") != "running":
@@ -149,6 +161,10 @@ def _kill_task(ui, task_id: str) -> bool:
149
161
  killed = asyncio.run(kill_background_task(task_id))
150
162
  except Exception as exc:
151
163
  console.print(f"[red]Error stopping task {escape(task_id)}: {escape(str(exc))}[/red]")
164
+ logger.exception(
165
+ "[tasks_cmd] Error stopping background task",
166
+ extra={"task_id": task_id, "session_id": getattr(ui, "session_id", None)},
167
+ )
152
168
  return True
153
169
 
154
170
  if killed:
@@ -160,7 +176,7 @@ def _kill_task(ui, task_id: str) -> bool:
160
176
  return True
161
177
 
162
178
 
163
- def _show_task(ui, task_id: str) -> bool:
179
+ def _show_task(ui: Any, task_id: str) -> bool:
164
180
  console = ui.console
165
181
  try:
166
182
  status = get_background_status(task_id, consume=False)
@@ -169,6 +185,10 @@ def _show_task(ui, task_id: str) -> bool:
169
185
  return True
170
186
  except Exception as exc:
171
187
  console.print(f"[red]Failed to read task '{escape(task_id)}': {escape(str(exc))}[/red]")
188
+ logger.exception(
189
+ "[tasks_cmd] Failed to read task for detail view",
190
+ extra={"task_id": task_id, "session_id": getattr(ui, "session_id", None)},
191
+ )
172
192
  return True
173
193
 
174
194
  details = Table(box=box.SIMPLE_HEAVY, show_header=False)
@@ -208,6 +228,13 @@ def _show_task(ui, task_id: str) -> bool:
208
228
 
209
229
  def _handle(ui: Any, args: str) -> bool:
210
230
  parts = args.split()
231
+ logger.info(
232
+ "[tasks_cmd] Handling /tasks command",
233
+ extra={
234
+ "session_id": getattr(ui, "session_id", None),
235
+ "raw_args": args,
236
+ },
237
+ )
211
238
  if not parts:
212
239
  return _list_tasks(ui)
213
240
 
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import re
6
- from typing import Any, Dict, List, Optional
6
+ from typing import Any, Dict, List, Optional, Tuple
7
7
 
8
8
  from ripperdoc.utils.message_compaction import ContextBreakdown
9
9
 
@@ -205,7 +205,8 @@ def make_segment_grid(
205
205
 
206
206
  rows: List[str] = []
207
207
  for start in range(0, total_slots, per_row):
208
- rows.append(" ".join(icons[start : start + per_row]))
208
+ row_icons = [icon for icon in icons[start : start + per_row] if icon is not None]
209
+ rows.append(" ".join(row_icons))
209
210
  return rows
210
211
 
211
212
 
@@ -229,7 +230,7 @@ def context_usage_lines(
229
230
  grid_lines.append(f" {row}")
230
231
 
231
232
  # Textual stats (without additional mini bars).
232
- stats = [
233
+ stats: List[Tuple[str, Optional[int], Optional[float]]] = [
233
234
  (
234
235
  f"{styled_symbol('⛁', 'grey58')} System prompt",
235
236
  breakdown.system_prompt_tokens,