ripperdoc 0.1.0__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 +25 -0
- ripperdoc/cli/__init__.py +1 -0
- ripperdoc/cli/cli.py +317 -0
- ripperdoc/cli/commands/__init__.py +76 -0
- ripperdoc/cli/commands/agents_cmd.py +234 -0
- ripperdoc/cli/commands/base.py +19 -0
- ripperdoc/cli/commands/clear_cmd.py +18 -0
- ripperdoc/cli/commands/compact_cmd.py +19 -0
- ripperdoc/cli/commands/config_cmd.py +31 -0
- ripperdoc/cli/commands/context_cmd.py +114 -0
- ripperdoc/cli/commands/cost_cmd.py +77 -0
- ripperdoc/cli/commands/exit_cmd.py +19 -0
- ripperdoc/cli/commands/help_cmd.py +20 -0
- ripperdoc/cli/commands/mcp_cmd.py +65 -0
- ripperdoc/cli/commands/models_cmd.py +327 -0
- ripperdoc/cli/commands/resume_cmd.py +97 -0
- ripperdoc/cli/commands/status_cmd.py +167 -0
- ripperdoc/cli/commands/tasks_cmd.py +240 -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 +297 -0
- ripperdoc/cli/ui/helpers.py +22 -0
- ripperdoc/cli/ui/rich_ui.py +1010 -0
- ripperdoc/cli/ui/spinner.py +50 -0
- ripperdoc/core/__init__.py +1 -0
- ripperdoc/core/agents.py +306 -0
- ripperdoc/core/commands.py +33 -0
- ripperdoc/core/config.py +382 -0
- ripperdoc/core/default_tools.py +57 -0
- ripperdoc/core/permissions.py +227 -0
- ripperdoc/core/query.py +682 -0
- ripperdoc/core/system_prompt.py +418 -0
- ripperdoc/core/tool.py +214 -0
- ripperdoc/sdk/__init__.py +9 -0
- ripperdoc/sdk/client.py +309 -0
- ripperdoc/tools/__init__.py +1 -0
- ripperdoc/tools/background_shell.py +291 -0
- ripperdoc/tools/bash_output_tool.py +98 -0
- ripperdoc/tools/bash_tool.py +822 -0
- ripperdoc/tools/file_edit_tool.py +281 -0
- ripperdoc/tools/file_read_tool.py +168 -0
- ripperdoc/tools/file_write_tool.py +141 -0
- ripperdoc/tools/glob_tool.py +134 -0
- ripperdoc/tools/grep_tool.py +232 -0
- ripperdoc/tools/kill_bash_tool.py +136 -0
- ripperdoc/tools/ls_tool.py +298 -0
- ripperdoc/tools/mcp_tools.py +804 -0
- ripperdoc/tools/multi_edit_tool.py +393 -0
- ripperdoc/tools/notebook_edit_tool.py +325 -0
- ripperdoc/tools/task_tool.py +282 -0
- ripperdoc/tools/todo_tool.py +362 -0
- ripperdoc/tools/tool_search_tool.py +366 -0
- ripperdoc/utils/__init__.py +1 -0
- ripperdoc/utils/bash_constants.py +51 -0
- ripperdoc/utils/bash_output_utils.py +43 -0
- ripperdoc/utils/exit_code_handlers.py +241 -0
- ripperdoc/utils/log.py +76 -0
- ripperdoc/utils/mcp.py +427 -0
- ripperdoc/utils/memory.py +239 -0
- ripperdoc/utils/message_compaction.py +640 -0
- ripperdoc/utils/messages.py +399 -0
- ripperdoc/utils/output_utils.py +233 -0
- ripperdoc/utils/path_utils.py +46 -0
- ripperdoc/utils/permissions/__init__.py +21 -0
- ripperdoc/utils/permissions/path_validation_utils.py +165 -0
- ripperdoc/utils/permissions/shell_command_validation.py +74 -0
- ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
- ripperdoc/utils/safe_get_cwd.py +24 -0
- ripperdoc/utils/sandbox_utils.py +38 -0
- ripperdoc/utils/session_history.py +223 -0
- ripperdoc/utils/session_usage.py +110 -0
- ripperdoc/utils/shell_token_utils.py +95 -0
- ripperdoc/utils/todo.py +199 -0
- ripperdoc-0.1.0.dist-info/METADATA +178 -0
- ripperdoc-0.1.0.dist-info/RECORD +81 -0
- ripperdoc-0.1.0.dist-info/WHEEL +5 -0
- ripperdoc-0.1.0.dist-info/entry_points.txt +3 -0
- ripperdoc-0.1.0.dist-info/licenses/LICENSE +53 -0
- ripperdoc-0.1.0.dist-info/top_level.txt +1 -0
ripperdoc/utils/mcp.py
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
"""MCP configuration loader, connection manager, and prompt helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextvars
|
|
7
|
+
import json
|
|
8
|
+
from contextlib import AsyncExitStack
|
|
9
|
+
from dataclasses import dataclass, field, replace
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
from ripperdoc import __version__
|
|
14
|
+
from ripperdoc.utils.log import get_logger
|
|
15
|
+
from ripperdoc.utils.message_compaction import estimate_tokens_from_text
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import mcp.types as mcp_types
|
|
19
|
+
from mcp.client.session import ClientSession
|
|
20
|
+
from mcp.client.sse import sse_client
|
|
21
|
+
from mcp.client.stdio import StdioServerParameters, stdio_client
|
|
22
|
+
from mcp.client.streamable_http import streamablehttp_client
|
|
23
|
+
|
|
24
|
+
MCP_AVAILABLE = True
|
|
25
|
+
except Exception: # pragma: no cover - handled gracefully at runtime
|
|
26
|
+
MCP_AVAILABLE = False
|
|
27
|
+
ClientSession = object # type: ignore
|
|
28
|
+
mcp_types = None # type: ignore
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
logger = get_logger()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class McpToolInfo:
|
|
36
|
+
name: str
|
|
37
|
+
description: str = ""
|
|
38
|
+
input_schema: Optional[Dict[str, Any]] = None
|
|
39
|
+
annotations: Dict[str, Any] = field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class McpResourceInfo:
|
|
44
|
+
uri: str
|
|
45
|
+
name: Optional[str] = None
|
|
46
|
+
description: str = ""
|
|
47
|
+
mime_type: Optional[str] = None
|
|
48
|
+
size: Optional[int] = None
|
|
49
|
+
text: Optional[str] = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class McpServerInfo:
|
|
54
|
+
name: str
|
|
55
|
+
type: str = "stdio"
|
|
56
|
+
url: Optional[str] = None
|
|
57
|
+
description: str = ""
|
|
58
|
+
command: Optional[str] = None
|
|
59
|
+
args: List[str] = field(default_factory=list)
|
|
60
|
+
env: Dict[str, str] = field(default_factory=dict)
|
|
61
|
+
headers: Dict[str, str] = field(default_factory=dict)
|
|
62
|
+
tools: List[McpToolInfo] = field(default_factory=list)
|
|
63
|
+
resources: List[McpResourceInfo] = field(default_factory=list)
|
|
64
|
+
status: str = "configured"
|
|
65
|
+
error: Optional[str] = None
|
|
66
|
+
instructions: Optional[str] = None
|
|
67
|
+
server_version: Optional[str] = None
|
|
68
|
+
capabilities: Dict[str, Any] = field(default_factory=dict)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _load_json_file(path: Path) -> Dict[str, Any]:
|
|
72
|
+
if not path.exists():
|
|
73
|
+
return {}
|
|
74
|
+
try:
|
|
75
|
+
return json.loads(path.read_text())
|
|
76
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
77
|
+
logger.error(f"Failed to load JSON from {path}: {exc}")
|
|
78
|
+
return {}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _ensure_str_dict(raw: object) -> Dict[str, str]:
|
|
82
|
+
if not isinstance(raw, dict):
|
|
83
|
+
return {}
|
|
84
|
+
result: Dict[str, str] = {}
|
|
85
|
+
for key, value in raw.items():
|
|
86
|
+
try:
|
|
87
|
+
result[str(key)] = str(value)
|
|
88
|
+
except Exception:
|
|
89
|
+
continue
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _parse_server(name: str, raw: Dict[str, Any]) -> McpServerInfo:
|
|
94
|
+
server_type = str(raw.get("type") or raw.get("transport") or "").strip().lower()
|
|
95
|
+
command = raw.get("command")
|
|
96
|
+
args = raw.get("args") if isinstance(raw.get("args"), list) else []
|
|
97
|
+
url = str(raw.get("url") or raw.get("uri") or "").strip() or None
|
|
98
|
+
|
|
99
|
+
if not server_type:
|
|
100
|
+
if url:
|
|
101
|
+
server_type = "sse"
|
|
102
|
+
elif command:
|
|
103
|
+
server_type = "stdio"
|
|
104
|
+
else:
|
|
105
|
+
server_type = "stdio"
|
|
106
|
+
|
|
107
|
+
description = str(raw.get("description") or "")
|
|
108
|
+
env = _ensure_str_dict(raw.get("env"))
|
|
109
|
+
headers = _ensure_str_dict(raw.get("headers"))
|
|
110
|
+
instructions = raw.get("instructions")
|
|
111
|
+
|
|
112
|
+
return McpServerInfo(
|
|
113
|
+
name=name,
|
|
114
|
+
type=server_type,
|
|
115
|
+
url=url,
|
|
116
|
+
description=description,
|
|
117
|
+
command=str(command) if isinstance(command, str) else None,
|
|
118
|
+
args=[str(a) for a in args] if args else [],
|
|
119
|
+
env=env,
|
|
120
|
+
headers=headers,
|
|
121
|
+
instructions=str(instructions) if isinstance(instructions, str) else None,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _parse_servers(data: Dict[str, Any]) -> Dict[str, McpServerInfo]:
|
|
126
|
+
servers: Dict[str, McpServerInfo] = {}
|
|
127
|
+
for key in ("servers", "mcpServers"):
|
|
128
|
+
raw_servers = data.get(key)
|
|
129
|
+
if not isinstance(raw_servers, dict):
|
|
130
|
+
continue
|
|
131
|
+
for name, raw in raw_servers.items():
|
|
132
|
+
if not isinstance(raw, dict):
|
|
133
|
+
continue
|
|
134
|
+
server_name = str(name).strip()
|
|
135
|
+
if not server_name:
|
|
136
|
+
continue
|
|
137
|
+
servers[server_name] = _parse_server(server_name, raw)
|
|
138
|
+
return servers
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _load_server_configs(project_path: Optional[Path]) -> Dict[str, McpServerInfo]:
|
|
142
|
+
project_path = project_path or Path.cwd()
|
|
143
|
+
candidates = [
|
|
144
|
+
Path.home() / ".ripperdoc" / "mcp.json",
|
|
145
|
+
Path.home() / ".mcp.json",
|
|
146
|
+
project_path / ".ripperdoc" / "mcp.json",
|
|
147
|
+
project_path / ".mcp.json",
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
merged: Dict[str, McpServerInfo] = {}
|
|
151
|
+
for path in candidates:
|
|
152
|
+
data = _load_json_file(path)
|
|
153
|
+
merged.update(_parse_servers(data))
|
|
154
|
+
return merged
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class McpRuntime:
|
|
158
|
+
"""Manages live MCP connections for the current event loop."""
|
|
159
|
+
|
|
160
|
+
def __init__(self, project_path: Path):
|
|
161
|
+
self.project_path = project_path
|
|
162
|
+
self._exit_stack = AsyncExitStack()
|
|
163
|
+
self.sessions: Dict[str, ClientSession] = {}
|
|
164
|
+
self.servers: List[McpServerInfo] = []
|
|
165
|
+
self._closed = False
|
|
166
|
+
|
|
167
|
+
async def connect(self, configs: Dict[str, McpServerInfo]) -> List[McpServerInfo]:
|
|
168
|
+
await self._exit_stack.__aenter__()
|
|
169
|
+
if not MCP_AVAILABLE:
|
|
170
|
+
for config in configs.values():
|
|
171
|
+
self.servers.append(
|
|
172
|
+
replace(
|
|
173
|
+
config,
|
|
174
|
+
status="unavailable",
|
|
175
|
+
error="MCP Python SDK not installed; install `mcp[cli]` with Python 3.10+.",
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
return self.servers
|
|
179
|
+
|
|
180
|
+
for config in configs.values():
|
|
181
|
+
self.servers.append(await self._connect_server(config))
|
|
182
|
+
return self.servers
|
|
183
|
+
|
|
184
|
+
async def _list_roots_callback(self, *_: Any, **__: Any):
|
|
185
|
+
if not mcp_types:
|
|
186
|
+
return None
|
|
187
|
+
return mcp_types.ListRootsResult(
|
|
188
|
+
roots=[mcp_types.Root(uri=Path(self.project_path).resolve().as_uri())]
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
async def _connect_server(self, config: McpServerInfo) -> McpServerInfo:
|
|
192
|
+
info = replace(config, tools=[], resources=[])
|
|
193
|
+
if not MCP_AVAILABLE or not mcp_types:
|
|
194
|
+
info.status = "unavailable"
|
|
195
|
+
info.error = "MCP Python SDK not installed."
|
|
196
|
+
return info
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
read_stream = None
|
|
200
|
+
write_stream = None
|
|
201
|
+
|
|
202
|
+
if config.type in ("sse", "sse-ide"):
|
|
203
|
+
if not config.url:
|
|
204
|
+
raise ValueError("SSE MCP server requires a 'url'.")
|
|
205
|
+
read_stream, write_stream = await self._exit_stack.enter_async_context(
|
|
206
|
+
sse_client(config.url, headers=config.headers or None)
|
|
207
|
+
)
|
|
208
|
+
elif config.type in ("http", "streamable-http"):
|
|
209
|
+
if not config.url:
|
|
210
|
+
raise ValueError("HTTP MCP server requires a 'url'.")
|
|
211
|
+
read_stream, write_stream, _ = await self._exit_stack.enter_async_context(
|
|
212
|
+
streamablehttp_client(
|
|
213
|
+
url=config.url,
|
|
214
|
+
headers=config.headers or None,
|
|
215
|
+
terminate_on_close=True,
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
else:
|
|
219
|
+
if not config.command:
|
|
220
|
+
raise ValueError("Stdio MCP server requires a 'command'.")
|
|
221
|
+
stdio_params = StdioServerParameters(
|
|
222
|
+
command=config.command,
|
|
223
|
+
args=config.args,
|
|
224
|
+
env=config.env or None,
|
|
225
|
+
cwd=self.project_path,
|
|
226
|
+
)
|
|
227
|
+
read_stream, write_stream = await self._exit_stack.enter_async_context(
|
|
228
|
+
stdio_client(stdio_params)
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
session = await self._exit_stack.enter_async_context(
|
|
232
|
+
ClientSession(
|
|
233
|
+
read_stream,
|
|
234
|
+
write_stream,
|
|
235
|
+
list_roots_callback=self._list_roots_callback,
|
|
236
|
+
client_info=mcp_types.Implementation(name="ripperdoc", version=__version__),
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
init_result = await session.initialize()
|
|
241
|
+
capabilities = session.get_server_capabilities()
|
|
242
|
+
|
|
243
|
+
info.status = "connected"
|
|
244
|
+
info.instructions = init_result.instructions or info.instructions
|
|
245
|
+
info.server_version = getattr(init_result.serverInfo, "version", None)
|
|
246
|
+
info.capabilities = (
|
|
247
|
+
capabilities.model_dump() if hasattr(capabilities, "model_dump") else {}
|
|
248
|
+
)
|
|
249
|
+
self.sessions[config.name] = session
|
|
250
|
+
|
|
251
|
+
tools_result = await session.list_tools()
|
|
252
|
+
info.tools = [
|
|
253
|
+
McpToolInfo(
|
|
254
|
+
name=tool.name,
|
|
255
|
+
description=tool.description or "",
|
|
256
|
+
input_schema=tool.inputSchema,
|
|
257
|
+
annotations=(tool.annotations.model_dump() if tool.annotations else {}),
|
|
258
|
+
)
|
|
259
|
+
for tool in tools_result.tools
|
|
260
|
+
]
|
|
261
|
+
|
|
262
|
+
if capabilities and getattr(capabilities, "resources", None):
|
|
263
|
+
resources_result = await session.list_resources()
|
|
264
|
+
info.resources = [
|
|
265
|
+
McpResourceInfo(
|
|
266
|
+
uri=str(resource.uri),
|
|
267
|
+
name=resource.name,
|
|
268
|
+
description=resource.description or "",
|
|
269
|
+
mime_type=resource.mimeType,
|
|
270
|
+
size=resource.size,
|
|
271
|
+
)
|
|
272
|
+
for resource in resources_result.resources
|
|
273
|
+
]
|
|
274
|
+
|
|
275
|
+
except Exception as exc: # pragma: no cover - network/process errors
|
|
276
|
+
logger.error(f"Failed to connect to MCP server '{config.name}': {exc}")
|
|
277
|
+
info.status = "failed"
|
|
278
|
+
info.error = str(exc)
|
|
279
|
+
|
|
280
|
+
return info
|
|
281
|
+
|
|
282
|
+
async def aclose(self) -> None:
|
|
283
|
+
if self._closed:
|
|
284
|
+
return
|
|
285
|
+
self._closed = True
|
|
286
|
+
try:
|
|
287
|
+
await self._exit_stack.aclose()
|
|
288
|
+
finally:
|
|
289
|
+
self.sessions.clear()
|
|
290
|
+
self.servers.clear()
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
_runtime_var: contextvars.ContextVar[Optional[McpRuntime]] = contextvars.ContextVar(
|
|
294
|
+
"ripperdoc_mcp_runtime", default=None
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _get_runtime() -> Optional[McpRuntime]:
|
|
299
|
+
return _runtime_var.get()
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def get_existing_mcp_runtime() -> Optional[McpRuntime]:
|
|
303
|
+
"""Return the current MCP runtime if it has already been initialized."""
|
|
304
|
+
return _get_runtime()
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
async def ensure_mcp_runtime(project_path: Optional[Path] = None) -> McpRuntime:
|
|
308
|
+
runtime = _get_runtime()
|
|
309
|
+
project_path = project_path or Path.cwd()
|
|
310
|
+
if runtime and not runtime._closed and runtime.project_path == project_path:
|
|
311
|
+
return runtime
|
|
312
|
+
|
|
313
|
+
if runtime:
|
|
314
|
+
await runtime.aclose()
|
|
315
|
+
|
|
316
|
+
runtime = McpRuntime(project_path)
|
|
317
|
+
configs = _load_server_configs(project_path)
|
|
318
|
+
await runtime.connect(configs)
|
|
319
|
+
_runtime_var.set(runtime)
|
|
320
|
+
return runtime
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
async def shutdown_mcp_runtime() -> None:
|
|
324
|
+
runtime = _get_runtime()
|
|
325
|
+
if not runtime:
|
|
326
|
+
return
|
|
327
|
+
await runtime.aclose()
|
|
328
|
+
_runtime_var.set(None)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
async def load_mcp_servers_async(project_path: Optional[Path] = None) -> List[McpServerInfo]:
|
|
332
|
+
runtime = await ensure_mcp_runtime(project_path)
|
|
333
|
+
return list(runtime.servers)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _config_only_servers(project_path: Optional[Path]) -> List[McpServerInfo]:
|
|
337
|
+
return list(_load_server_configs(project_path).values())
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def load_mcp_servers(project_path: Optional[Path] = None) -> List[McpServerInfo]:
|
|
341
|
+
"""Synchronous wrapper primarily for legacy call sites."""
|
|
342
|
+
try:
|
|
343
|
+
loop = asyncio.get_running_loop()
|
|
344
|
+
if loop.is_running():
|
|
345
|
+
runtime = _get_runtime()
|
|
346
|
+
if runtime and runtime.servers:
|
|
347
|
+
return list(runtime.servers)
|
|
348
|
+
return _config_only_servers(project_path)
|
|
349
|
+
except RuntimeError:
|
|
350
|
+
pass
|
|
351
|
+
|
|
352
|
+
return asyncio.run(load_mcp_servers_async(project_path))
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def find_mcp_resource(
|
|
356
|
+
servers: List[McpServerInfo],
|
|
357
|
+
server_name: str,
|
|
358
|
+
uri: str,
|
|
359
|
+
) -> Optional[McpResourceInfo]:
|
|
360
|
+
server = next((s for s in servers if s.name == server_name), None)
|
|
361
|
+
if not server:
|
|
362
|
+
return None
|
|
363
|
+
return next((r for r in server.resources if r.uri == uri), None)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _summarize_tools(server: McpServerInfo) -> str:
|
|
367
|
+
if not server.tools:
|
|
368
|
+
return "no tools"
|
|
369
|
+
names = [tool.name for tool in server.tools[:6]]
|
|
370
|
+
suffix = ", ".join(names)
|
|
371
|
+
if len(server.tools) > 6:
|
|
372
|
+
suffix += f", and {len(server.tools) - 6} more"
|
|
373
|
+
return suffix
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def format_mcp_instructions(servers: List[McpServerInfo]) -> str:
|
|
377
|
+
"""Build a concise MCP instruction block for the system prompt."""
|
|
378
|
+
if not servers:
|
|
379
|
+
return ""
|
|
380
|
+
|
|
381
|
+
lines: List[str] = [
|
|
382
|
+
"MCP servers are available. Call tools via CallMcpTool by specifying server, tool, and arguments.",
|
|
383
|
+
"Use ListMcpServers to inspect servers and ListMcpResources/ReadMcpResource when a server exposes resources.",
|
|
384
|
+
]
|
|
385
|
+
|
|
386
|
+
for server in servers:
|
|
387
|
+
status = server.status or "unknown"
|
|
388
|
+
prefix = f"- {server.name} [{status}]"
|
|
389
|
+
if server.url:
|
|
390
|
+
prefix += f" {server.url}"
|
|
391
|
+
lines.append(prefix)
|
|
392
|
+
|
|
393
|
+
if status == "connected":
|
|
394
|
+
if server.instructions:
|
|
395
|
+
trimmed = server.instructions.strip()
|
|
396
|
+
if len(trimmed) > 260:
|
|
397
|
+
trimmed = trimmed[:257] + "..."
|
|
398
|
+
lines.append(f" Instructions: {trimmed}")
|
|
399
|
+
tool_summary = _summarize_tools(server)
|
|
400
|
+
lines.append(f" Tools: {tool_summary}")
|
|
401
|
+
if server.resources:
|
|
402
|
+
lines.append(f" Resources: {len(server.resources)} available")
|
|
403
|
+
elif server.error:
|
|
404
|
+
lines.append(f" Error: {server.error}")
|
|
405
|
+
|
|
406
|
+
return "\n".join(lines)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def estimate_mcp_tokens(servers: List[McpServerInfo]) -> int:
|
|
410
|
+
"""Estimate token usage for MCP instructions."""
|
|
411
|
+
mcp_text = format_mcp_instructions(servers)
|
|
412
|
+
return estimate_tokens_from_text(mcp_text)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
__all__ = [
|
|
416
|
+
"McpServerInfo",
|
|
417
|
+
"McpToolInfo",
|
|
418
|
+
"McpResourceInfo",
|
|
419
|
+
"get_existing_mcp_runtime",
|
|
420
|
+
"load_mcp_servers",
|
|
421
|
+
"load_mcp_servers_async",
|
|
422
|
+
"ensure_mcp_runtime",
|
|
423
|
+
"shutdown_mcp_runtime",
|
|
424
|
+
"find_mcp_resource",
|
|
425
|
+
"format_mcp_instructions",
|
|
426
|
+
"estimate_mcp_tokens",
|
|
427
|
+
]
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Helpers for loading AGENTS.md memory files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import List, Optional, Set
|
|
9
|
+
|
|
10
|
+
MEMORY_FILE_NAME = "AGENTS.md"
|
|
11
|
+
LOCAL_MEMORY_FILE_NAME = "AGENTS.local.md"
|
|
12
|
+
|
|
13
|
+
MEMORY_INSTRUCTIONS = (
|
|
14
|
+
"Codebase and user instructions are shown below. Be sure to adhere to these "
|
|
15
|
+
"instructions. IMPORTANT: These instructions OVERRIDE any default behavior "
|
|
16
|
+
"and you MUST follow them exactly as written."
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
MAX_CONTENT_LENGTH = 40_000
|
|
20
|
+
MAX_INCLUDE_DEPTH = 5
|
|
21
|
+
|
|
22
|
+
_CODE_FENCE_RE = re.compile(r"```.*?```", flags=re.DOTALL)
|
|
23
|
+
_INLINE_CODE_RE = re.compile(r"`[^`]*`")
|
|
24
|
+
_MENTION_RE = re.compile(r"(?:^|\s)@((?:[^\s\\]|\\ )+)")
|
|
25
|
+
_PUNCT_START_RE = re.compile(r"^[#%^&*()]+")
|
|
26
|
+
_VALID_START_RE = re.compile(r"^[A-Za-z0-9._-]")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class MemoryFile:
|
|
31
|
+
"""Representation of a loaded memory file."""
|
|
32
|
+
|
|
33
|
+
path: str
|
|
34
|
+
type: str
|
|
35
|
+
content: str
|
|
36
|
+
parent: Optional[str] = None
|
|
37
|
+
is_nested: bool = False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_path_under_directory(path: Path, directory: Path) -> bool:
|
|
41
|
+
"""Return True if path is inside directory (after resolving)."""
|
|
42
|
+
try:
|
|
43
|
+
path.resolve().relative_to(directory.resolve())
|
|
44
|
+
return True
|
|
45
|
+
except Exception:
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _resolve_relative_path(raw_path: str, base_path: Path) -> Path:
|
|
50
|
+
"""Resolve a mention (./foo, ~/bar, /abs, or relative) against a base file."""
|
|
51
|
+
normalized = raw_path.replace("\\ ", " ")
|
|
52
|
+
if normalized.startswith("~/"):
|
|
53
|
+
return (Path.home() / normalized[2:]).resolve()
|
|
54
|
+
candidate = Path(normalized)
|
|
55
|
+
if not candidate.is_absolute():
|
|
56
|
+
return (base_path.parent / candidate).resolve()
|
|
57
|
+
return candidate.resolve()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _read_file_with_type(file_path: Path, file_type: str) -> Optional[MemoryFile]:
|
|
61
|
+
"""Read a file if it exists, returning a MemoryFile entry."""
|
|
62
|
+
try:
|
|
63
|
+
if not file_path.exists() or not file_path.is_file():
|
|
64
|
+
return None
|
|
65
|
+
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
|
66
|
+
return MemoryFile(path=str(file_path), type=file_type, content=content)
|
|
67
|
+
except PermissionError:
|
|
68
|
+
return None
|
|
69
|
+
except OSError:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _extract_relative_paths_from_markdown(markdown_content: str, base_path: Path) -> List[Path]:
|
|
74
|
+
"""Extract @-mentions that look like file paths from markdown content."""
|
|
75
|
+
if not markdown_content:
|
|
76
|
+
return []
|
|
77
|
+
|
|
78
|
+
cleaned = _CODE_FENCE_RE.sub("", markdown_content)
|
|
79
|
+
cleaned = _INLINE_CODE_RE.sub("", cleaned)
|
|
80
|
+
|
|
81
|
+
relative_paths: Set[Path] = set()
|
|
82
|
+
for match in _MENTION_RE.finditer(cleaned):
|
|
83
|
+
mention = (match.group(1) or "").replace("\\ ", " ").strip()
|
|
84
|
+
if not mention or mention.startswith("@"):
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
if not (
|
|
88
|
+
mention.startswith("./")
|
|
89
|
+
or mention.startswith("~/")
|
|
90
|
+
or (mention.startswith("/") and mention != "/")
|
|
91
|
+
or (not _PUNCT_START_RE.match(mention) and _VALID_START_RE.match(mention))
|
|
92
|
+
):
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
resolved = _resolve_relative_path(mention, base_path)
|
|
96
|
+
relative_paths.add(resolved)
|
|
97
|
+
|
|
98
|
+
return list(relative_paths)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _collect_files(
|
|
102
|
+
file_path: Path,
|
|
103
|
+
file_type: str,
|
|
104
|
+
visited: Set[str],
|
|
105
|
+
allow_outside_cwd: bool,
|
|
106
|
+
depth: int = 0,
|
|
107
|
+
parent_path: Optional[Path] = None,
|
|
108
|
+
) -> List[MemoryFile]:
|
|
109
|
+
"""Collect a memory file and any nested references."""
|
|
110
|
+
if depth >= MAX_INCLUDE_DEPTH:
|
|
111
|
+
return []
|
|
112
|
+
|
|
113
|
+
resolved_path = file_path.expanduser()
|
|
114
|
+
try:
|
|
115
|
+
resolved_path = resolved_path.resolve()
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
resolved_key = str(resolved_path)
|
|
120
|
+
if resolved_key in visited:
|
|
121
|
+
return []
|
|
122
|
+
|
|
123
|
+
current_file = _read_file_with_type(resolved_path, file_type)
|
|
124
|
+
if not current_file or not current_file.content.strip():
|
|
125
|
+
return []
|
|
126
|
+
|
|
127
|
+
if parent_path is not None:
|
|
128
|
+
current_file.parent = str(parent_path)
|
|
129
|
+
current_file.is_nested = depth > 0
|
|
130
|
+
|
|
131
|
+
visited.add(resolved_key)
|
|
132
|
+
|
|
133
|
+
collected: List[MemoryFile] = [current_file]
|
|
134
|
+
relative_paths = _extract_relative_paths_from_markdown(current_file.content, resolved_path)
|
|
135
|
+
for nested_path in relative_paths:
|
|
136
|
+
if not allow_outside_cwd and not _is_path_under_directory(nested_path, Path.cwd()):
|
|
137
|
+
continue
|
|
138
|
+
collected.extend(
|
|
139
|
+
_collect_files(
|
|
140
|
+
nested_path,
|
|
141
|
+
file_type,
|
|
142
|
+
visited,
|
|
143
|
+
allow_outside_cwd,
|
|
144
|
+
depth + 1,
|
|
145
|
+
resolved_path,
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
return collected
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def collect_all_memory_files(force_include_external: bool = False) -> List[MemoryFile]:
|
|
153
|
+
"""Collect all AGENTS memory files reachable from the working directory."""
|
|
154
|
+
visited: Set[str] = set()
|
|
155
|
+
files: List[MemoryFile] = []
|
|
156
|
+
|
|
157
|
+
# Global/user-level memories live in home and ~/.ripperdoc.
|
|
158
|
+
user_memory_paths = [
|
|
159
|
+
Path.home() / ".ripperdoc" / MEMORY_FILE_NAME,
|
|
160
|
+
Path.home() / MEMORY_FILE_NAME,
|
|
161
|
+
]
|
|
162
|
+
for user_memory_path in user_memory_paths:
|
|
163
|
+
files.extend(
|
|
164
|
+
_collect_files(
|
|
165
|
+
user_memory_path,
|
|
166
|
+
"User",
|
|
167
|
+
visited,
|
|
168
|
+
allow_outside_cwd=True,
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Project memories from the current working directory up to the filesystem root.
|
|
173
|
+
ancestor_dirs: List[Path] = []
|
|
174
|
+
current_dir = Path.cwd()
|
|
175
|
+
while True:
|
|
176
|
+
ancestor_dirs.append(current_dir)
|
|
177
|
+
if current_dir.parent == current_dir:
|
|
178
|
+
break
|
|
179
|
+
current_dir = current_dir.parent
|
|
180
|
+
|
|
181
|
+
for directory in reversed(ancestor_dirs):
|
|
182
|
+
files.extend(
|
|
183
|
+
_collect_files(
|
|
184
|
+
directory / MEMORY_FILE_NAME,
|
|
185
|
+
"Project",
|
|
186
|
+
visited,
|
|
187
|
+
allow_outside_cwd=force_include_external,
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
files.extend(
|
|
191
|
+
_collect_files(
|
|
192
|
+
directory / LOCAL_MEMORY_FILE_NAME,
|
|
193
|
+
"Local",
|
|
194
|
+
visited,
|
|
195
|
+
allow_outside_cwd=force_include_external,
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
return files
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def get_oversized_memory_files() -> List[MemoryFile]:
|
|
203
|
+
"""Return memory files that exceed the recommended length."""
|
|
204
|
+
return [file for file in collect_all_memory_files() if len(file.content) > MAX_CONTENT_LENGTH]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def build_memory_instructions() -> str:
|
|
208
|
+
"""Build the instruction block to append to the system prompt."""
|
|
209
|
+
memory_files = collect_all_memory_files()
|
|
210
|
+
snippets: List[str] = []
|
|
211
|
+
for memory_file in memory_files:
|
|
212
|
+
if not memory_file.content:
|
|
213
|
+
continue
|
|
214
|
+
type_description = (
|
|
215
|
+
" (project instructions, checked into the codebase)"
|
|
216
|
+
if memory_file.type == "Project"
|
|
217
|
+
else (
|
|
218
|
+
" (user's private project instructions, not checked in)"
|
|
219
|
+
if memory_file.type == "Local"
|
|
220
|
+
else " (user's private global instructions)"
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
snippets.append(
|
|
224
|
+
f"Contents of {memory_file.path}{type_description}:\n\n{memory_file.content}"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
if not snippets:
|
|
228
|
+
return ""
|
|
229
|
+
|
|
230
|
+
return f"{MEMORY_INSTRUCTIONS}\n\n" + "\n\n".join(snippets)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
__all__ = [
|
|
234
|
+
"MemoryFile",
|
|
235
|
+
"collect_all_memory_files",
|
|
236
|
+
"build_memory_instructions",
|
|
237
|
+
"get_oversized_memory_files",
|
|
238
|
+
"MAX_CONTENT_LENGTH",
|
|
239
|
+
]
|