ripperdoc 0.2.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 (51) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +66 -8
  3. ripperdoc/cli/commands/__init__.py +4 -0
  4. ripperdoc/cli/commands/agents_cmd.py +22 -0
  5. ripperdoc/cli/commands/context_cmd.py +11 -1
  6. ripperdoc/cli/commands/doctor_cmd.py +200 -0
  7. ripperdoc/cli/commands/memory_cmd.py +209 -0
  8. ripperdoc/cli/commands/models_cmd.py +25 -0
  9. ripperdoc/cli/commands/tasks_cmd.py +27 -0
  10. ripperdoc/cli/ui/rich_ui.py +156 -9
  11. ripperdoc/core/agents.py +4 -2
  12. ripperdoc/core/config.py +48 -3
  13. ripperdoc/core/default_tools.py +16 -2
  14. ripperdoc/core/permissions.py +19 -0
  15. ripperdoc/core/query.py +231 -297
  16. ripperdoc/core/query_utils.py +537 -0
  17. ripperdoc/core/system_prompt.py +2 -1
  18. ripperdoc/core/tool.py +13 -0
  19. ripperdoc/tools/background_shell.py +9 -3
  20. ripperdoc/tools/bash_tool.py +15 -0
  21. ripperdoc/tools/file_edit_tool.py +7 -0
  22. ripperdoc/tools/file_read_tool.py +7 -0
  23. ripperdoc/tools/file_write_tool.py +7 -0
  24. ripperdoc/tools/glob_tool.py +55 -15
  25. ripperdoc/tools/grep_tool.py +7 -0
  26. ripperdoc/tools/ls_tool.py +242 -73
  27. ripperdoc/tools/mcp_tools.py +32 -10
  28. ripperdoc/tools/multi_edit_tool.py +11 -0
  29. ripperdoc/tools/notebook_edit_tool.py +6 -3
  30. ripperdoc/tools/task_tool.py +7 -0
  31. ripperdoc/tools/todo_tool.py +159 -25
  32. ripperdoc/tools/tool_search_tool.py +9 -0
  33. ripperdoc/utils/git_utils.py +276 -0
  34. ripperdoc/utils/json_utils.py +28 -0
  35. ripperdoc/utils/log.py +130 -29
  36. ripperdoc/utils/mcp.py +71 -6
  37. ripperdoc/utils/memory.py +14 -1
  38. ripperdoc/utils/message_compaction.py +26 -5
  39. ripperdoc/utils/messages.py +63 -4
  40. ripperdoc/utils/output_utils.py +36 -9
  41. ripperdoc/utils/permissions/path_validation_utils.py +6 -0
  42. ripperdoc/utils/safe_get_cwd.py +4 -0
  43. ripperdoc/utils/session_history.py +27 -9
  44. ripperdoc/utils/todo.py +2 -2
  45. {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.2.dist-info}/METADATA +4 -2
  46. ripperdoc-0.2.2.dist-info/RECORD +86 -0
  47. ripperdoc-0.2.0.dist-info/RECORD +0 -81
  48. {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.2.dist-info}/WHEEL +0 -0
  49. {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.2.dist-info}/entry_points.txt +0 -0
  50. {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.2.dist-info}/licenses/LICENSE +0 -0
  51. {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.2.dist-info}/top_level.txt +0 -0
ripperdoc/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """Ripperdoc - AI-powered coding agent."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.2"
ripperdoc/cli/cli.py CHANGED
@@ -6,6 +6,7 @@ This module provides the command-line interface for the Ripperdoc agent.
6
6
  import asyncio
7
7
  import click
8
8
  import sys
9
+ import uuid
9
10
  from pathlib import Path
10
11
  from typing import Any, Dict, List, Optional
11
12
 
@@ -29,6 +30,7 @@ from ripperdoc.utils.mcp import (
29
30
  shutdown_mcp_runtime,
30
31
  )
31
32
  from ripperdoc.tools.mcp_tools import load_dynamic_mcp_tools_async, merge_tools_with_dynamic
33
+ from ripperdoc.utils.log import enable_session_file_logging, get_logger
32
34
 
33
35
  from rich.console import Console
34
36
  from rich.markdown import Markdown
@@ -36,13 +38,33 @@ from rich.panel import Panel
36
38
  from rich.markup import escape
37
39
 
38
40
  console = Console()
41
+ logger = get_logger()
39
42
 
40
43
 
41
44
  async def run_query(
42
- prompt: str, tools: list, safe_mode: bool = False, verbose: bool = False
45
+ prompt: str,
46
+ tools: list,
47
+ safe_mode: bool = False,
48
+ verbose: bool = False,
49
+ session_id: Optional[str] = None,
43
50
  ) -> None:
44
51
  """Run a single query and print the response."""
45
52
 
53
+ logger.info(
54
+ "[cli] Running single prompt session",
55
+ extra={
56
+ "safe_mode": safe_mode,
57
+ "verbose": verbose,
58
+ "session_id": session_id,
59
+ "prompt_length": len(prompt),
60
+ },
61
+ )
62
+ if prompt:
63
+ logger.debug(
64
+ "[cli] Prompt preview",
65
+ extra={"session_id": session_id, "prompt_preview": prompt[:200]},
66
+ )
67
+
46
68
  project_path = Path.cwd()
47
69
  can_use_tool = make_permission_checker(project_path, safe_mode) if safe_mode else None
48
70
 
@@ -125,12 +147,18 @@ async def run_query(
125
147
  console.print("\n[yellow]Interrupted by user[/yellow]")
126
148
  except Exception as e:
127
149
  console.print(f"[red]Error: {escape(str(e))}[/red]")
150
+ logger.exception("[cli] Unhandled error while running prompt", extra={"session_id": session_id})
128
151
  if verbose:
129
152
  import traceback
130
153
 
131
154
  console.print(traceback.format_exc(), markup=False)
155
+ logger.info(
156
+ "[cli] Prompt session completed",
157
+ extra={"session_id": session_id, "message_count": len(messages)},
158
+ )
132
159
  finally:
133
160
  await shutdown_mcp_runtime()
161
+ logger.debug("[cli] Shutdown MCP runtime", extra={"session_id": session_id})
134
162
 
135
163
 
136
164
  def check_onboarding() -> bool:
@@ -243,27 +271,51 @@ def cli(
243
271
  ctx: click.Context, cwd: Optional[str], unsafe: bool, verbose: bool, prompt: Optional[str]
244
272
  ) -> None:
245
273
  """Ripperdoc - AI-powered coding agent"""
246
-
247
- # Ensure onboarding is complete
248
- if not check_onboarding():
249
- sys.exit(1)
274
+ session_id = str(uuid.uuid4())
250
275
 
251
276
  # Set working directory
252
277
  if cwd:
253
278
  import os
254
279
 
255
280
  os.chdir(cwd)
281
+ logger.debug(
282
+ "[cli] Changed working directory via --cwd",
283
+ extra={"cwd": cwd, "session_id": session_id},
284
+ )
256
285
 
257
- # Initialize project configuration for the current working directory
258
286
  project_path = Path.cwd()
287
+ log_file = enable_session_file_logging(project_path, session_id)
288
+ logger.info(
289
+ "[cli] Starting CLI invocation",
290
+ extra={
291
+ "session_id": session_id,
292
+ "project_path": str(project_path),
293
+ "log_file": str(log_file),
294
+ "prompt_mode": bool(prompt),
295
+ },
296
+ )
297
+
298
+ # Ensure onboarding is complete
299
+ if not check_onboarding():
300
+ logger.info(
301
+ "[cli] Onboarding check failed or aborted; exiting.",
302
+ extra={"session_id": session_id},
303
+ )
304
+ sys.exit(1)
305
+
306
+ # Initialize project configuration for the current working directory
259
307
  get_project_config(project_path)
260
308
 
261
309
  safe_mode = not unsafe
310
+ logger.debug(
311
+ "[cli] Configuration initialized",
312
+ extra={"session_id": session_id, "safe_mode": safe_mode, "verbose": verbose},
313
+ )
262
314
 
263
315
  # If prompt is provided, run directly
264
316
  if prompt:
265
317
  tools = get_default_tools()
266
- asyncio.run(run_query(prompt, tools, safe_mode, verbose))
318
+ asyncio.run(run_query(prompt, tools, safe_mode, verbose, session_id=session_id))
267
319
  return
268
320
 
269
321
  # If no command specified, start interactive REPL with Rich interface
@@ -271,7 +323,12 @@ def cli(
271
323
  # Use Rich interface by default
272
324
  from ripperdoc.cli.ui.rich_ui import main_rich
273
325
 
274
- main_rich(safe_mode=safe_mode, verbose=verbose)
326
+ main_rich(
327
+ safe_mode=safe_mode,
328
+ verbose=verbose,
329
+ session_id=session_id,
330
+ log_file_path=log_file,
331
+ )
275
332
  return
276
333
 
277
334
 
@@ -312,6 +369,7 @@ def main() -> None:
312
369
  sys.exit(130)
313
370
  except Exception as e:
314
371
  console.print(f"[red]Fatal error: {escape(str(e))}[/red]")
372
+ logger.exception("[cli] Fatal error in main CLI entrypoint")
315
373
  sys.exit(1)
316
374
 
317
375
 
@@ -11,8 +11,10 @@ from .compact_cmd import command as compact_command
11
11
  from .config_cmd import command as config_command
12
12
  from .cost_cmd import command as cost_command
13
13
  from .context_cmd import command as context_command
14
+ from .doctor_cmd import command as doctor_command
14
15
  from .exit_cmd import command as exit_command
15
16
  from .help_cmd import command as help_command
17
+ from .memory_cmd import command as memory_command
16
18
  from .mcp_cmd import command as mcp_command
17
19
  from .models_cmd import command as models_command
18
20
  from .resume_cmd import command as resume_command
@@ -40,6 +42,8 @@ ALL_COMMANDS: List[SlashCommand] = [
40
42
  models_command,
41
43
  exit_command,
42
44
  status_command,
45
+ doctor_command,
46
+ memory_command,
43
47
  tasks_command,
44
48
  todos_command,
45
49
  mcp_command,
@@ -8,15 +8,25 @@ from ripperdoc.core.agents import (
8
8
  save_agent_definition,
9
9
  )
10
10
  from ripperdoc.core.config import get_global_config
11
+ from ripperdoc.utils.log import get_logger
11
12
 
12
13
  from typing import Any
13
14
  from .base import SlashCommand
14
15
 
16
+ logger = get_logger()
17
+
15
18
 
16
19
  def _handle(ui: Any, trimmed_arg: str) -> bool:
17
20
  console = ui.console
18
21
  tokens = trimmed_arg.split()
19
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
+ )
20
30
 
21
31
  def print_agents_usage() -> None:
22
32
  console.print("[bold]/agents[/bold] — list configured agents")
@@ -100,6 +110,10 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
100
110
  except Exception as exc:
101
111
  console.print(f"[red]Failed to create agent: {escape(str(exc))}[/red]")
102
112
  print_agents_usage()
113
+ logger.exception(
114
+ "[agents_cmd] Failed to create agent",
115
+ extra={"agent": agent_name, "session_id": getattr(ui, "session_id", None)},
116
+ )
103
117
  return True
104
118
 
105
119
  if subcmd in ("delete", "del", "remove"):
@@ -127,6 +141,10 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
127
141
  except Exception as exc:
128
142
  console.print(f"[red]Failed to delete agent: {escape(str(exc))}[/red]")
129
143
  print_agents_usage()
144
+ logger.exception(
145
+ "[agents_cmd] Failed to delete agent",
146
+ extra={"agent": agent_name, "session_id": getattr(ui, "session_id", None)},
147
+ )
130
148
  return True
131
149
 
132
150
  if subcmd in ("edit", "update"):
@@ -200,6 +218,10 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
200
218
  except Exception as exc:
201
219
  console.print(f"[red]Failed to update agent: {escape(str(exc))}[/red]")
202
220
  print_agents_usage()
221
+ logger.exception(
222
+ "[agents_cmd] Failed to update agent",
223
+ extra={"agent": agent_name, "session_id": getattr(ui, "session_id", None)},
224
+ )
203
225
  return True
204
226
 
205
227
  agents = load_agent_definitions()
@@ -20,11 +20,18 @@ from ripperdoc.utils.mcp import (
20
20
  load_mcp_servers_async,
21
21
  shutdown_mcp_runtime,
22
22
  )
23
+ from ripperdoc.utils.log import get_logger
23
24
 
24
25
  from .base import SlashCommand
25
26
 
27
+ logger = get_logger()
28
+
26
29
 
27
30
  def _handle(ui: Any, _: str) -> bool:
31
+ logger.info(
32
+ "[context_cmd] Rendering context summary",
33
+ extra={"session_id": getattr(ui, "session_id", None)},
34
+ )
28
35
  config = get_global_config()
29
36
  model_profile = get_profile_for_pointer("main")
30
37
  max_context_tokens = get_remaining_context_tokens(model_profile, config.context_token_limit)
@@ -98,7 +105,10 @@ def _handle(ui: Any, _: str) -> bool:
98
105
  if len(mcp_tools) > 20:
99
106
  lines.append(f" └ ... (+{len(mcp_tools) - 20} more)")
100
107
  except Exception:
101
- pass
108
+ logger.exception(
109
+ "[context_cmd] Failed to summarize MCP tools",
110
+ extra={"session_id": getattr(ui, "session_id", None)},
111
+ )
102
112
  for line in lines:
103
113
  ui.console.print(line)
104
114
  return True
@@ -0,0 +1,200 @@
1
+ """Slash command to diagnose common setup issues."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+ from typing import Any, List, Optional, Tuple
8
+
9
+ from rich.markup import escape
10
+ from rich.panel import Panel
11
+ from rich.table import Table
12
+
13
+ from ripperdoc.core.config import (
14
+ ProviderType,
15
+ api_key_env_candidates,
16
+ get_global_config,
17
+ get_project_config,
18
+ )
19
+ from ripperdoc.cli.ui.helpers import get_profile_for_pointer
20
+ from ripperdoc.utils.log import get_logger
21
+ from ripperdoc.utils.mcp import load_mcp_servers_async, shutdown_mcp_runtime
22
+ from ripperdoc.utils.sandbox_utils import is_sandbox_available
23
+
24
+ from .base import SlashCommand
25
+
26
+ logger = get_logger()
27
+
28
+
29
+ def _status_row(label: str, status: str, detail: str = "") -> Tuple[str, str, str]:
30
+ """Build a (label, status, detail) tuple with icon."""
31
+ icons = {
32
+ "ok": "[green]✓[/green]",
33
+ "warn": "[yellow]![/yellow]",
34
+ "error": "[red]×[/red]",
35
+ }
36
+ icon = icons.get(status, "[yellow]?[/yellow]")
37
+ return (label, icon, detail)
38
+
39
+
40
+ def _api_key_status(provider: ProviderType, profile_key: Optional[str]) -> Tuple[str, str]:
41
+ """Check API key presence and source."""
42
+ import os
43
+ for env_var in api_key_env_candidates(provider):
44
+ if os.environ.get(env_var):
45
+ masked = os.environ[env_var]
46
+ masked = masked[:4] + "…" if len(masked) > 4 else "set"
47
+ return ("ok", f"Found in ${env_var} ({masked})")
48
+
49
+ if profile_key:
50
+ return ("ok", "Stored in config profile")
51
+
52
+ return ("error", "Missing API key for active provider; set $ENV or edit config")
53
+
54
+
55
+ def _model_status(project_path: Path) -> List[Tuple[str, str, str]]:
56
+ config = get_global_config()
57
+ pointer = getattr(config.model_pointers, "main", "default")
58
+ profile = get_profile_for_pointer("main")
59
+ rows: List[Tuple[str, str, str]] = []
60
+
61
+ if not profile:
62
+ rows.append(_status_row("Model profile", "error", "No profile configured for pointer 'main'"))
63
+ return rows
64
+
65
+ if pointer not in config.model_profiles:
66
+ rows.append(
67
+ _status_row(
68
+ "Model pointer",
69
+ "warn",
70
+ f"Pointer 'main' targets '{pointer}' which is missing; using fallback.",
71
+ )
72
+ )
73
+ rows.append(
74
+ _status_row(
75
+ "Model",
76
+ "ok",
77
+ f"{profile.model} ({profile.provider.value})",
78
+ )
79
+ )
80
+
81
+ key_status, key_detail = _api_key_status(profile.provider, profile.api_key)
82
+ rows.append(_status_row("API key", key_status, key_detail))
83
+ return rows
84
+
85
+
86
+ def _onboarding_status() -> Tuple[str, str, str]:
87
+ config = get_global_config()
88
+ if config.has_completed_onboarding:
89
+ return _status_row(
90
+ "Onboarding",
91
+ "ok",
92
+ f"Completed (version {str(config.last_onboarding_version or 'unknown')})",
93
+ )
94
+ return _status_row(
95
+ "Onboarding",
96
+ "warn",
97
+ "Not completed; run the CLI without flags to configure provider/model.",
98
+ )
99
+
100
+
101
+ def _sandbox_status() -> Tuple[str, str, str]:
102
+ available = is_sandbox_available()
103
+ if available:
104
+ return _status_row("Sandbox", "ok", "'srt' runtime is available")
105
+ return _status_row("Sandbox", "warn", "Sandbox runtime not detected; commands run normally")
106
+
107
+
108
+ def _mcp_status(project_path: Path) -> Tuple[List[Tuple[str, str, str]], List[str]]:
109
+ """Return MCP status rows and errors."""
110
+ rows: List[Tuple[str, str, str]] = []
111
+ errors: List[str] = []
112
+
113
+ async def _load() -> List[Any]:
114
+ try:
115
+ return await load_mcp_servers_async(project_path)
116
+ finally:
117
+ await shutdown_mcp_runtime()
118
+
119
+ try:
120
+ servers = asyncio.run(_load())
121
+ except Exception as exc: # pragma: no cover - defensive
122
+ logger.exception("[doctor] Failed to load MCP servers", exc_info=exc)
123
+ rows.append(_status_row("MCP", "error", f"Failed to load MCP config: {exc}"))
124
+ return rows, errors
125
+
126
+ if not servers:
127
+ rows.append(_status_row("MCP", "warn", "No MCP servers configured (.mcp.json)"))
128
+ return rows, errors
129
+
130
+ failing = [s for s in servers if getattr(s, "error", None)]
131
+ rows.append(
132
+ _status_row(
133
+ "MCP",
134
+ "ok" if not failing else "warn",
135
+ f"{len(servers)} configured; {len(failing)} with errors",
136
+ )
137
+ )
138
+ for server in failing[:5]:
139
+ errors.append(f"{server.name}: {server.error}")
140
+ if len(failing) > 5:
141
+ errors.append(f"... {len(failing) - 5} more")
142
+ return rows, errors
143
+
144
+
145
+ def _project_status(project_path: Path) -> Tuple[str, str, str]:
146
+ try:
147
+ config = get_project_config(project_path)
148
+ # Access a field to ensure model parsing does not throw.
149
+ _ = len(config.allowed_tools)
150
+ return _status_row("Project config", "ok", f".ripperdoc/config.json loaded for {project_path}")
151
+ except Exception as exc: # pragma: no cover - defensive
152
+ logger.exception("[doctor] Failed to load project config", exc_info=exc)
153
+ return _status_row("Project config", "warn", f"Could not read .ripperdoc/config.json: {exc}")
154
+
155
+
156
+ def _render_table(console: Any, rows: List[Tuple[str, str, str]]) -> None:
157
+ table = Table(show_header=True, header_style="bold cyan")
158
+ table.add_column("Check")
159
+ table.add_column("")
160
+ table.add_column("Details")
161
+ for label, status, detail in rows:
162
+ table.add_row(label, status, escape(detail) if detail else "")
163
+ console.print(table)
164
+
165
+
166
+ def _handle(ui: Any, _: str) -> bool:
167
+ project_path = getattr(ui, "project_path", Path.cwd())
168
+ results: List[Tuple[str, str, str]] = []
169
+
170
+ results.append(_onboarding_status())
171
+ results.extend(_model_status(project_path))
172
+ project_row = _project_status(project_path)
173
+ results.append(project_row)
174
+
175
+ mcp_rows, mcp_errors = _mcp_status(project_path)
176
+ results.extend(mcp_rows)
177
+ results.append(_sandbox_status())
178
+
179
+ ui.console.print(Panel("Environment diagnostics", title="/doctor", border_style="cyan"))
180
+ _render_table(ui.console, results)
181
+
182
+ if mcp_errors:
183
+ ui.console.print("\n[bold]MCP issues:[/bold]")
184
+ for err in mcp_errors:
185
+ ui.console.print(f" • {escape(err)}")
186
+
187
+ ui.console.print(
188
+ "\n[dim]If a check is failing, run `ripperdoc` without flags to rerun onboarding or update ~/.ripperdoc.json[/dim]"
189
+ )
190
+ return True
191
+
192
+
193
+ command = SlashCommand(
194
+ name="doctor",
195
+ description="Diagnose model config, API keys, MCP, and sandbox support",
196
+ handler=_handle,
197
+ )
198
+
199
+
200
+ __all__ = ["command"]
@@ -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"]