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,263 @@
1
+ from rich.markup import escape
2
+
3
+ from ripperdoc.core.agents import (
4
+ AGENT_DIR_NAME,
5
+ AgentLocation,
6
+ delete_agent_definition,
7
+ load_agent_definitions,
8
+ save_agent_definition,
9
+ )
10
+ from ripperdoc.core.config import get_global_config
11
+ from ripperdoc.utils.log import get_logger
12
+
13
+ from typing import Any
14
+ from .base import SlashCommand
15
+
16
+ logger = get_logger()
17
+
18
+
19
+ def _handle(ui: Any, trimmed_arg: str) -> bool:
20
+ console = ui.console
21
+ tokens = trimmed_arg.split()
22
+ subcmd = tokens[0].lower() if tokens else ""
23
+ logger.info(
24
+ "[agents_cmd] Handling /agents command",
25
+ extra={
26
+ "subcommand": subcmd or "list",
27
+ "session_id": getattr(ui, "session_id", None),
28
+ },
29
+ )
30
+
31
+ def print_agents_usage() -> None:
32
+ console.print("[bold]/agents[/bold] — list configured agents")
33
+ console.print(
34
+ "[bold]/agents create <name> [location] [model][/bold] — "
35
+ "create agent (location: user|project, default user)"
36
+ )
37
+ console.print("[bold]/agents edit <name> [location][/bold] — edit an existing agent")
38
+ console.print(
39
+ "[bold]/agents delete <name> [location][/bold] — "
40
+ "delete agent (location: user|project, default user)"
41
+ )
42
+ console.print(
43
+ f"[dim]Agent files live in ~/.ripperdoc/{AGENT_DIR_NAME} "
44
+ f"or ./.ripperdoc/{AGENT_DIR_NAME}[/dim]"
45
+ )
46
+ console.print(
47
+ "[dim]Model can be a profile name or pointer (task/main/etc). Defaults to 'task'.[/dim]"
48
+ )
49
+
50
+ if subcmd in ("help", "-h", "--help"):
51
+ print_agents_usage()
52
+ return True
53
+
54
+ if subcmd in ("create", "add"):
55
+ agent_name = tokens[1] if len(tokens) > 1 else console.input("Agent name: ").strip()
56
+ if not agent_name:
57
+ console.print("[red]Agent name is required.[/red]")
58
+ print_agents_usage()
59
+ return True
60
+
61
+ description = console.input("Description (when to use this agent): ").strip()
62
+ if not description:
63
+ console.print("[red]Description is required.[/red]")
64
+ return True
65
+
66
+ tools_input = console.input("Tools (comma-separated, * for all) [*]: ").strip() or "*"
67
+ tools = [t.strip() for t in tools_input.split(",") if t.strip()] or ["*"]
68
+
69
+ system_prompt = console.input("System prompt (single line, use \\n for newlines): ").strip()
70
+ if not system_prompt:
71
+ console.print("[red]System prompt is required.[/red]")
72
+ print_agents_usage()
73
+ return True
74
+
75
+ location_arg = tokens[2] if len(tokens) > 2 else ""
76
+ model_arg = tokens[3] if len(tokens) > 3 else ""
77
+ if location_arg and location_arg.lower() not in ("user", "project"):
78
+ model_arg, location_arg = location_arg, ""
79
+
80
+ location_raw = (
81
+ location_arg or console.input("Location [user/project, default user]: ").strip()
82
+ ).lower()
83
+ location = AgentLocation.PROJECT if location_raw == "project" else AgentLocation.USER
84
+
85
+ config = get_global_config()
86
+ pointer_map = config.model_pointers.model_dump()
87
+ default_model_value = model_arg or pointer_map.get("task", "task")
88
+ model_input = (
89
+ console.input(f"Model profile or pointer [{default_model_value}]: ").strip()
90
+ or default_model_value
91
+ )
92
+ if (
93
+ model_input
94
+ and model_input not in config.model_profiles
95
+ and model_input not in pointer_map
96
+ ):
97
+ console.print(
98
+ "[yellow]Model not found in profiles or pointers; "
99
+ "will fall back to main if unavailable.[/yellow]"
100
+ )
101
+
102
+ try:
103
+ path = save_agent_definition(
104
+ agent_type=agent_name,
105
+ description=description,
106
+ tools=tools,
107
+ system_prompt=system_prompt,
108
+ location=location,
109
+ model=model_input,
110
+ )
111
+ console.print(
112
+ f"[green]✓ Agent '{escape(agent_name)}' created at {escape(str(path))}[/green]"
113
+ )
114
+ except (OSError, IOError, PermissionError, ValueError) as exc:
115
+ console.print(f"[red]Failed to create agent: {escape(str(exc))}[/red]")
116
+ print_agents_usage()
117
+ logger.warning(
118
+ "[agents_cmd] Failed to create agent: %s: %s",
119
+ type(exc).__name__, exc,
120
+ extra={"agent": agent_name, "session_id": getattr(ui, "session_id", None)},
121
+ )
122
+ return True
123
+
124
+ if subcmd in ("delete", "del", "remove"):
125
+ agent_name = (
126
+ tokens[1] if len(tokens) > 1 else console.input("Agent name to delete: ").strip()
127
+ )
128
+ if not agent_name:
129
+ console.print("[red]Agent name is required.[/red]")
130
+ print_agents_usage()
131
+ return True
132
+
133
+ location_raw = (
134
+ tokens[2]
135
+ if len(tokens) > 2
136
+ else console.input("Location to delete from [user/project, default user]: ").strip()
137
+ ).lower()
138
+ location = AgentLocation.PROJECT if location_raw == "project" else AgentLocation.USER
139
+ try:
140
+ path = delete_agent_definition(agent_name, location)
141
+ console.print(
142
+ f"[green]✓ Deleted agent '{escape(agent_name)}' at {escape(str(path))}[/green]"
143
+ )
144
+ except FileNotFoundError as exc:
145
+ console.print(f"[yellow]{escape(str(exc))}[/yellow]")
146
+ except (OSError, IOError, PermissionError, ValueError) as exc:
147
+ console.print(f"[red]Failed to delete agent: {escape(str(exc))}[/red]")
148
+ print_agents_usage()
149
+ logger.warning(
150
+ "[agents_cmd] Failed to delete agent: %s: %s",
151
+ type(exc).__name__, exc,
152
+ extra={"agent": agent_name, "session_id": getattr(ui, "session_id", None)},
153
+ )
154
+ return True
155
+
156
+ if subcmd in ("edit", "update"):
157
+ agent_name = tokens[1] if len(tokens) > 1 else console.input("Agent to edit: ").strip()
158
+ if not agent_name:
159
+ console.print("[red]Agent name is required.[/red]")
160
+ print_agents_usage()
161
+ return True
162
+
163
+ agents = load_agent_definitions()
164
+ target_agent = next((a for a in agents.active_agents if a.agent_type == agent_name), None)
165
+ if not target_agent:
166
+ console.print(f"[red]Agent '{escape(agent_name)}' not found.[/red]")
167
+ print_agents_usage()
168
+ return True
169
+ if target_agent.location == AgentLocation.BUILT_IN:
170
+ console.print("[yellow]Built-in agents cannot be edited.[/yellow]")
171
+ return True
172
+
173
+ default_location = target_agent.location
174
+ location_raw = (
175
+ tokens[2]
176
+ if len(tokens) > 2
177
+ else console.input(
178
+ f"Location to save [user/project, default {default_location.value}]: "
179
+ ).strip()
180
+ ).lower()
181
+ location = AgentLocation.PROJECT if location_raw == "project" else AgentLocation.USER
182
+
183
+ description = (
184
+ console.input(f"Description (when to use) [{target_agent.when_to_use}]: ").strip()
185
+ or target_agent.when_to_use
186
+ )
187
+
188
+ tools_default = "*" if "*" in target_agent.tools else ", ".join(target_agent.tools)
189
+ tools_input = (
190
+ console.input(f"Tools (comma-separated, * for all) [{tools_default}]: ").strip()
191
+ or tools_default
192
+ )
193
+ tools = [t.strip() for t in tools_input.split(",") if t.strip()] or ["*"]
194
+
195
+ console.print("[dim]Current system prompt:[/dim]")
196
+ console.print(escape(target_agent.system_prompt or "(empty)"), markup=False)
197
+ system_prompt = (
198
+ console.input(
199
+ "System prompt (single line, use \\n for newlines) [Enter to keep current]: "
200
+ ).strip()
201
+ or target_agent.system_prompt
202
+ )
203
+
204
+ config = get_global_config()
205
+ pointer_map = config.model_pointers.model_dump()
206
+ model_default = target_agent.model or pointer_map.get("task", "task")
207
+ model_input = (
208
+ console.input(f"Model profile or pointer [{model_default}]: ").strip() or model_default
209
+ )
210
+
211
+ try:
212
+ path = save_agent_definition(
213
+ agent_type=agent_name,
214
+ description=description,
215
+ tools=tools,
216
+ system_prompt=system_prompt,
217
+ location=location,
218
+ model=model_input,
219
+ overwrite=True,
220
+ )
221
+ console.print(
222
+ f"[green]✓ Agent '{escape(agent_name)}' updated at {escape(str(path))}[/green]"
223
+ )
224
+ except (OSError, IOError, PermissionError, ValueError) as exc:
225
+ console.print(f"[red]Failed to update agent: {escape(str(exc))}[/red]")
226
+ print_agents_usage()
227
+ logger.warning(
228
+ "[agents_cmd] Failed to update agent: %s: %s",
229
+ type(exc).__name__, exc,
230
+ extra={"agent": agent_name, "session_id": getattr(ui, "session_id", None)},
231
+ )
232
+ return True
233
+
234
+ agents = load_agent_definitions()
235
+ console.print("\n[bold]Agents:[/bold]")
236
+ print_agents_usage()
237
+ if not agents.active_agents:
238
+ console.print(" • None configured")
239
+ for agent in agents.active_agents:
240
+ location = getattr(agent.location, "value", agent.location)
241
+ tools_str = "all tools" if "*" in agent.tools else ", ".join(agent.tools)
242
+ console.print(f" • {escape(agent.agent_type)} ({escape(str(location))})", markup=False)
243
+ console.print(f" {escape(agent.when_to_use)}", markup=False)
244
+ console.print(f" tools: {escape(tools_str)}", markup=False)
245
+ console.print(f" model: {escape(agent.model or 'task (default)')}", markup=False)
246
+ if agents.failed_files:
247
+ console.print("[yellow]Some agent files could not be loaded:[/yellow]")
248
+ for path, error in agents.failed_files:
249
+ console.print(f" - {escape(str(path))}: {escape(str(error))}", markup=False)
250
+ console.print(
251
+ f"[dim]Add agents in ~/.ripperdoc/{AGENT_DIR_NAME} or ./.ripperdoc/{AGENT_DIR_NAME}[/dim]"
252
+ )
253
+ return True
254
+
255
+
256
+ command = SlashCommand(
257
+ name="agents",
258
+ description="Manage subagents: list/create/delete",
259
+ handler=_handle,
260
+ )
261
+
262
+
263
+ __all__ = ["command"]
@@ -0,0 +1,19 @@
1
+ """Shared types for slash command handlers."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Callable, Tuple
5
+
6
+ Handler = Callable[[Any, str], bool]
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class SlashCommand:
11
+ """A single slash command implementation."""
12
+
13
+ name: str
14
+ description: str
15
+ handler: Handler
16
+ aliases: Tuple[str, ...] = ()
17
+
18
+
19
+ __all__ = ["SlashCommand", "Handler"]
@@ -0,0 +1,18 @@
1
+ from typing import Any
2
+ from .base import SlashCommand
3
+
4
+
5
+ def _handle(ui: Any, _: str) -> bool:
6
+ ui.conversation_messages = []
7
+ ui.console.print("[green]✓ Conversation cleared[/green]")
8
+ return True
9
+
10
+
11
+ command = SlashCommand(
12
+ name="clear",
13
+ description="Clear conversation history",
14
+ handler=_handle,
15
+ )
16
+
17
+
18
+ __all__ = ["command"]
@@ -0,0 +1,23 @@
1
+ from typing import Any
2
+ from .base import SlashCommand
3
+
4
+
5
+ def _handle(ui: Any, trimmed_arg: str) -> bool:
6
+ runner = getattr(ui, "run_async", None)
7
+ if callable(runner):
8
+ runner(ui._run_manual_compact(trimmed_arg))
9
+ else:
10
+ import asyncio
11
+
12
+ asyncio.run(ui._run_manual_compact(trimmed_arg))
13
+ return True
14
+
15
+
16
+ command = SlashCommand(
17
+ name="compact",
18
+ description="Compact conversation history",
19
+ handler=_handle,
20
+ )
21
+
22
+
23
+ __all__ = ["command"]
@@ -0,0 +1,31 @@
1
+ from rich.markup import escape
2
+
3
+ from ripperdoc.core.config import get_global_config
4
+ from ripperdoc.cli.ui.helpers import get_profile_for_pointer
5
+
6
+ from typing import Any
7
+ from .base import SlashCommand
8
+
9
+
10
+ def _handle(ui: Any, _: str) -> bool:
11
+ config = get_global_config()
12
+ profile = get_profile_for_pointer("main")
13
+ main_pointer = getattr(config.model_pointers, "main", "default")
14
+ model_label = profile.model if profile else "Not configured"
15
+
16
+ ui.console.print(
17
+ f"\n[bold]Model (main -> {escape(str(main_pointer))}):[/bold] {escape(str(model_label))}"
18
+ )
19
+ ui.console.print(f"[bold]Safe Mode:[/bold] {escape(str(ui.safe_mode))}")
20
+ ui.console.print(f"[bold]Verbose:[/bold] {escape(str(ui.verbose))}")
21
+ return True
22
+
23
+
24
+ command = SlashCommand(
25
+ name="config",
26
+ description="Show current configuration",
27
+ handler=_handle,
28
+ )
29
+
30
+
31
+ __all__ = ["command"]
@@ -0,0 +1,144 @@
1
+ import json
2
+ from typing import List, Any
3
+
4
+ from ripperdoc.cli.ui.helpers import get_profile_for_pointer
5
+ from ripperdoc.cli.ui.context_display import format_tokens
6
+ from ripperdoc.core.config import get_global_config, provider_protocol
7
+ from ripperdoc.core.query import QueryContext
8
+ from ripperdoc.core.system_prompt import build_system_prompt
9
+ from ripperdoc.core.skills import build_skill_summary, load_all_skills
10
+ from ripperdoc.utils.memory import build_memory_instructions
11
+ from ripperdoc.utils.message_compaction import (
12
+ get_remaining_context_tokens,
13
+ resolve_auto_compact_enabled,
14
+ summarize_context_usage,
15
+ )
16
+ from ripperdoc.utils.token_estimation import estimate_tokens
17
+ from ripperdoc.utils.mcp import (
18
+ estimate_mcp_tokens,
19
+ format_mcp_instructions,
20
+ load_mcp_servers_async,
21
+ )
22
+ from ripperdoc.utils.log import get_logger
23
+
24
+ from .base import SlashCommand
25
+
26
+ logger = get_logger()
27
+
28
+
29
+ def _handle(ui: Any, _: str) -> bool:
30
+ logger.info(
31
+ "[context_cmd] Rendering context summary",
32
+ extra={"session_id": getattr(ui, "session_id", None)},
33
+ )
34
+ config = get_global_config()
35
+ model_profile = get_profile_for_pointer("main")
36
+ max_context_tokens = get_remaining_context_tokens(model_profile, config.context_token_limit)
37
+ auto_compact_enabled = resolve_auto_compact_enabled(config)
38
+ protocol = provider_protocol(model_profile.provider) if model_profile else "openai"
39
+
40
+ if not ui.query_context:
41
+ ui.query_context = QueryContext(
42
+ tools=ui.get_default_tools(),
43
+ safe_mode=ui.safe_mode,
44
+ verbose=ui.verbose,
45
+ )
46
+
47
+ async def _load_servers() -> List[Any]:
48
+ return await load_mcp_servers_async(ui.project_path)
49
+
50
+ runner = getattr(ui, "run_async", None)
51
+ if callable(runner):
52
+ servers = runner(_load_servers())
53
+ else:
54
+ import asyncio
55
+
56
+ servers = asyncio.run(_load_servers())
57
+ mcp_instructions = format_mcp_instructions(servers)
58
+ skill_result = load_all_skills(ui.project_path)
59
+ for err in skill_result.errors:
60
+ logger.warning(
61
+ "[skills] Failed to load skill",
62
+ extra={
63
+ "path": str(err.path),
64
+ "reason": err.reason,
65
+ "session_id": getattr(ui, "session_id", None),
66
+ },
67
+ )
68
+ skill_instructions = build_skill_summary(skill_result.skills)
69
+ additional_instructions: List[str] = []
70
+ if skill_instructions:
71
+ additional_instructions.append(skill_instructions)
72
+ memory_instructions = build_memory_instructions()
73
+ if memory_instructions:
74
+ additional_instructions.append(memory_instructions)
75
+ base_system_prompt = build_system_prompt(
76
+ ui.query_context.tools,
77
+ "",
78
+ {},
79
+ additional_instructions=additional_instructions or None,
80
+ mcp_instructions=mcp_instructions,
81
+ )
82
+ memory_tokens = 0
83
+ mcp_tokens = estimate_mcp_tokens(servers) if mcp_instructions else 0
84
+
85
+ breakdown = summarize_context_usage(
86
+ ui.conversation_messages,
87
+ ui.query_context.tools,
88
+ base_system_prompt,
89
+ max_context_tokens,
90
+ auto_compact_enabled,
91
+ memory_tokens=memory_tokens,
92
+ mcp_tokens=mcp_tokens,
93
+ protocol=protocol,
94
+ )
95
+
96
+ model_label = model_profile.model if model_profile else "Unknown model"
97
+ lines = ui._context_usage_lines(breakdown, model_label, auto_compact_enabled)
98
+
99
+ lines.append("")
100
+ # Append a brief tool listing so users can see which tools are currently loaded.
101
+ try:
102
+ # Detailed MCP tool listing with token estimates.
103
+ mcp_tools = [
104
+ tool
105
+ for tool in getattr(ui.query_context, "tool_registry", ui.query_context).all_tools
106
+ if getattr(tool, "is_mcp", False) or getattr(tool, "name", "").startswith("mcp__")
107
+ ]
108
+ if mcp_tools:
109
+ lines.append(" MCP tools · /mcp")
110
+ for tool in mcp_tools[:20]:
111
+ name = getattr(tool, "name", "unknown")
112
+ display = name
113
+ parts = name.split("__")
114
+ if len(parts) >= 3 and parts[0] == "mcp":
115
+ server = parts[1]
116
+ display = "__".join(parts[2:])
117
+ display = f"{display} ({server})"
118
+ try:
119
+ schema = tool.input_schema.model_json_schema()
120
+ token_est = estimate_tokens(json.dumps(schema, sort_keys=True))
121
+ except (AttributeError, TypeError, ValueError):
122
+ token_est = 0
123
+ lines.append(f" └ {display}: {format_tokens(token_est)} tokens")
124
+ if len(mcp_tools) > 20:
125
+ lines.append(f" └ ... (+{len(mcp_tools) - 20} more)")
126
+ except (OSError, RuntimeError, AttributeError, TypeError) as exc:
127
+ logger.warning(
128
+ "[context_cmd] Failed to summarize MCP tools: %s: %s",
129
+ type(exc).__name__, exc,
130
+ extra={"session_id": getattr(ui, "session_id", None)},
131
+ )
132
+ for line in lines:
133
+ ui.console.print(line)
134
+ return True
135
+
136
+
137
+ command = SlashCommand(
138
+ name="context",
139
+ description="Show current conversation context summary",
140
+ handler=_handle,
141
+ )
142
+
143
+
144
+ __all__ = ["command"]
@@ -0,0 +1,82 @@
1
+ from ripperdoc.utils.session_usage import get_session_usage
2
+
3
+ from typing import Any
4
+ from .base import SlashCommand
5
+
6
+
7
+ def _fmt_tokens(value: int) -> str:
8
+ """Format integers with thousand separators."""
9
+ return f"{int(value):,}"
10
+
11
+
12
+ def _format_duration(duration_ms: float) -> str:
13
+ """Render milliseconds into a compact human-readable duration."""
14
+ seconds = int(duration_ms // 1000)
15
+ if seconds < 60:
16
+ return f"{duration_ms / 1000:.2f}s"
17
+ minutes, secs = divmod(seconds, 60)
18
+ if minutes < 60:
19
+ return f"{minutes}m {secs}s"
20
+ hours, mins = divmod(minutes, 60)
21
+ return f"{hours}h {mins}m {secs}s"
22
+
23
+
24
+ def _handle(ui: Any, _: str) -> bool:
25
+ usage = get_session_usage()
26
+ if not usage.models:
27
+ ui.console.print("[yellow]No model usage recorded yet.[/yellow]")
28
+ return True
29
+
30
+ total_input = usage.total_input_tokens
31
+ total_output = usage.total_output_tokens
32
+ total_cache_read = usage.total_cache_read_tokens
33
+ total_cache_creation = usage.total_cache_creation_tokens
34
+ total_tokens = total_input + total_output + total_cache_read + total_cache_creation
35
+ total_cost = usage.total_cost_usd
36
+
37
+ ui.console.print("\n[bold]Session token usage[/bold]")
38
+ ui.console.print(
39
+ f" Total: {_fmt_tokens(total_tokens)} tokens "
40
+ f"(input {_fmt_tokens(total_input)}, output {_fmt_tokens(total_output)})"
41
+ )
42
+ if total_cache_read or total_cache_creation:
43
+ ui.console.print(
44
+ f" Cache: {_fmt_tokens(total_cache_read)} read, "
45
+ f"{_fmt_tokens(total_cache_creation)} write"
46
+ )
47
+ ui.console.print(f" Requests: {usage.total_requests}")
48
+ if total_cost:
49
+ ui.console.print(f" Cost: ${total_cost:.4f}")
50
+ if usage.total_duration_ms:
51
+ ui.console.print(f" API time: {_format_duration(usage.total_duration_ms)}")
52
+
53
+ ui.console.print("\n[bold]By model:[/bold]")
54
+ for model_name, stats in usage.models.items():
55
+ line = (
56
+ f" {model_name}: "
57
+ f"{_fmt_tokens(stats.input_tokens)} in, "
58
+ f"{_fmt_tokens(stats.output_tokens)} out"
59
+ )
60
+ if stats.cache_read_input_tokens:
61
+ line += f", {_fmt_tokens(stats.cache_read_input_tokens)} cache read"
62
+ if stats.cache_creation_input_tokens:
63
+ line += f", {_fmt_tokens(stats.cache_creation_input_tokens)} cache write"
64
+ line += f" ({stats.requests} call{'' if stats.requests == 1 else 's'}"
65
+ if stats.duration_ms:
66
+ line += f", {_format_duration(stats.duration_ms)} total"
67
+ line += ")"
68
+ if stats.cost_usd:
69
+ line += f", ${stats.cost_usd:.4f}"
70
+ ui.console.print(line)
71
+
72
+ return True
73
+
74
+
75
+ command = SlashCommand(
76
+ name="cost",
77
+ description="Show total tokens used in this session",
78
+ handler=_handle,
79
+ )
80
+
81
+
82
+ __all__ = ["command"]