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.
Files changed (81) hide show
  1. ripperdoc/__init__.py +3 -0
  2. ripperdoc/__main__.py +25 -0
  3. ripperdoc/cli/__init__.py +1 -0
  4. ripperdoc/cli/cli.py +317 -0
  5. ripperdoc/cli/commands/__init__.py +76 -0
  6. ripperdoc/cli/commands/agents_cmd.py +234 -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 +19 -0
  10. ripperdoc/cli/commands/config_cmd.py +31 -0
  11. ripperdoc/cli/commands/context_cmd.py +114 -0
  12. ripperdoc/cli/commands/cost_cmd.py +77 -0
  13. ripperdoc/cli/commands/exit_cmd.py +19 -0
  14. ripperdoc/cli/commands/help_cmd.py +20 -0
  15. ripperdoc/cli/commands/mcp_cmd.py +65 -0
  16. ripperdoc/cli/commands/models_cmd.py +327 -0
  17. ripperdoc/cli/commands/resume_cmd.py +97 -0
  18. ripperdoc/cli/commands/status_cmd.py +167 -0
  19. ripperdoc/cli/commands/tasks_cmd.py +240 -0
  20. ripperdoc/cli/commands/todos_cmd.py +69 -0
  21. ripperdoc/cli/commands/tools_cmd.py +19 -0
  22. ripperdoc/cli/ui/__init__.py +1 -0
  23. ripperdoc/cli/ui/context_display.py +297 -0
  24. ripperdoc/cli/ui/helpers.py +22 -0
  25. ripperdoc/cli/ui/rich_ui.py +1010 -0
  26. ripperdoc/cli/ui/spinner.py +50 -0
  27. ripperdoc/core/__init__.py +1 -0
  28. ripperdoc/core/agents.py +306 -0
  29. ripperdoc/core/commands.py +33 -0
  30. ripperdoc/core/config.py +382 -0
  31. ripperdoc/core/default_tools.py +57 -0
  32. ripperdoc/core/permissions.py +227 -0
  33. ripperdoc/core/query.py +682 -0
  34. ripperdoc/core/system_prompt.py +418 -0
  35. ripperdoc/core/tool.py +214 -0
  36. ripperdoc/sdk/__init__.py +9 -0
  37. ripperdoc/sdk/client.py +309 -0
  38. ripperdoc/tools/__init__.py +1 -0
  39. ripperdoc/tools/background_shell.py +291 -0
  40. ripperdoc/tools/bash_output_tool.py +98 -0
  41. ripperdoc/tools/bash_tool.py +822 -0
  42. ripperdoc/tools/file_edit_tool.py +281 -0
  43. ripperdoc/tools/file_read_tool.py +168 -0
  44. ripperdoc/tools/file_write_tool.py +141 -0
  45. ripperdoc/tools/glob_tool.py +134 -0
  46. ripperdoc/tools/grep_tool.py +232 -0
  47. ripperdoc/tools/kill_bash_tool.py +136 -0
  48. ripperdoc/tools/ls_tool.py +298 -0
  49. ripperdoc/tools/mcp_tools.py +804 -0
  50. ripperdoc/tools/multi_edit_tool.py +393 -0
  51. ripperdoc/tools/notebook_edit_tool.py +325 -0
  52. ripperdoc/tools/task_tool.py +282 -0
  53. ripperdoc/tools/todo_tool.py +362 -0
  54. ripperdoc/tools/tool_search_tool.py +366 -0
  55. ripperdoc/utils/__init__.py +1 -0
  56. ripperdoc/utils/bash_constants.py +51 -0
  57. ripperdoc/utils/bash_output_utils.py +43 -0
  58. ripperdoc/utils/exit_code_handlers.py +241 -0
  59. ripperdoc/utils/log.py +76 -0
  60. ripperdoc/utils/mcp.py +427 -0
  61. ripperdoc/utils/memory.py +239 -0
  62. ripperdoc/utils/message_compaction.py +640 -0
  63. ripperdoc/utils/messages.py +399 -0
  64. ripperdoc/utils/output_utils.py +233 -0
  65. ripperdoc/utils/path_utils.py +46 -0
  66. ripperdoc/utils/permissions/__init__.py +21 -0
  67. ripperdoc/utils/permissions/path_validation_utils.py +165 -0
  68. ripperdoc/utils/permissions/shell_command_validation.py +74 -0
  69. ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
  70. ripperdoc/utils/safe_get_cwd.py +24 -0
  71. ripperdoc/utils/sandbox_utils.py +38 -0
  72. ripperdoc/utils/session_history.py +223 -0
  73. ripperdoc/utils/session_usage.py +110 -0
  74. ripperdoc/utils/shell_token_utils.py +95 -0
  75. ripperdoc/utils/todo.py +199 -0
  76. ripperdoc-0.1.0.dist-info/METADATA +178 -0
  77. ripperdoc-0.1.0.dist-info/RECORD +81 -0
  78. ripperdoc-0.1.0.dist-info/WHEEL +5 -0
  79. ripperdoc-0.1.0.dist-info/entry_points.txt +3 -0
  80. ripperdoc-0.1.0.dist-info/licenses/LICENSE +53 -0
  81. 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
+ ]