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,274 @@
1
+ """Git utilities for Ripperdoc."""
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+ from typing import Dict, List, Optional, Tuple
6
+ import fnmatch
7
+
8
+
9
+ def is_git_repository(path: Path) -> bool:
10
+ """Check if a directory is a git repository."""
11
+ try:
12
+ result = subprocess.run(
13
+ ["git", "rev-parse", "--is-inside-work-tree"],
14
+ cwd=path,
15
+ capture_output=True,
16
+ text=True,
17
+ timeout=5,
18
+ )
19
+ return result.returncode == 0 and result.stdout.strip() == "true"
20
+ except (subprocess.SubprocessError, FileNotFoundError):
21
+ return False
22
+
23
+
24
+ def get_git_root(path: Path) -> Optional[Path]:
25
+ """Get the git root directory for a given path."""
26
+ try:
27
+ result = subprocess.run(
28
+ ["git", "rev-parse", "--show-toplevel"],
29
+ cwd=path,
30
+ capture_output=True,
31
+ text=True,
32
+ timeout=5,
33
+ )
34
+ if result.returncode == 0:
35
+ return Path(result.stdout.strip())
36
+ return None
37
+ except (subprocess.SubprocessError, FileNotFoundError):
38
+ return None
39
+
40
+
41
+ def read_gitignore_patterns(path: Path) -> List[str]:
42
+ """Read .gitignore patterns from a directory and its parent directories."""
43
+ patterns: List[str] = []
44
+ current = path
45
+
46
+ # Read .gitignore from current directory up to git root
47
+ git_root = get_git_root(path)
48
+
49
+ while current and (git_root is None or current.is_relative_to(git_root)):
50
+ gitignore_file = current / ".gitignore"
51
+ if gitignore_file.exists():
52
+ try:
53
+ with open(gitignore_file, "r", encoding="utf-8") as f:
54
+ for line in f:
55
+ line = line.strip()
56
+ if line and not line.startswith("#"):
57
+ patterns.append(line)
58
+ except (IOError, UnicodeDecodeError):
59
+ pass
60
+
61
+ # Also check for .git/info/exclude
62
+ git_info_exclude = current / ".git" / "info" / "exclude"
63
+ if git_info_exclude.exists():
64
+ try:
65
+ with open(git_info_exclude, "r", encoding="utf-8") as f:
66
+ for line in f:
67
+ line = line.strip()
68
+ if line and not line.startswith("#"):
69
+ patterns.append(line)
70
+ except (IOError, UnicodeDecodeError):
71
+ pass
72
+
73
+ if current.parent == current: # Reached root
74
+ break
75
+ current = current.parent
76
+
77
+ # Add global gitignore patterns
78
+ global_gitignore = Path.home() / ".gitignore"
79
+ if global_gitignore.exists():
80
+ try:
81
+ with open(global_gitignore, "r", encoding="utf-8") as f:
82
+ for line in f:
83
+ line = line.strip()
84
+ if line and not line.startswith("#"):
85
+ patterns.append(line)
86
+ except (IOError, UnicodeDecodeError):
87
+ pass
88
+
89
+ return patterns
90
+
91
+
92
+ def parse_gitignore_pattern(pattern: str, root_path: Path) -> Tuple[str, Optional[Path]]:
93
+ """Parse a gitignore pattern and return (relative_pattern, root)."""
94
+ pattern = pattern.strip()
95
+
96
+ # Handle absolute paths
97
+ if pattern.startswith("/"):
98
+ return pattern[1:], root_path
99
+
100
+ # Handle patterns relative to home directory
101
+ if pattern.startswith("~/"):
102
+ home_pattern = pattern[2:]
103
+ return home_pattern, Path.home()
104
+
105
+ # Handle patterns with leading slash (relative to repository root)
106
+ if pattern.startswith("/"):
107
+ return pattern[1:], root_path
108
+
109
+ # Default: pattern is relative to the directory containing .gitignore
110
+ return pattern, None
111
+
112
+
113
+ def build_ignore_patterns_map(
114
+ root_path: Path,
115
+ user_ignore_patterns: Optional[List[str]] = None,
116
+ include_gitignore: bool = True,
117
+ ) -> Dict[Optional[Path], List[str]]:
118
+ """Build a map of ignore patterns by root directory."""
119
+ ignore_map: Dict[Optional[Path], List[str]] = {}
120
+
121
+ # Add user-provided ignore patterns
122
+ if user_ignore_patterns:
123
+ for pattern in user_ignore_patterns:
124
+ relative_pattern, pattern_root = parse_gitignore_pattern(pattern, root_path)
125
+ if pattern_root not in ignore_map:
126
+ ignore_map[pattern_root] = []
127
+ ignore_map[pattern_root].append(relative_pattern)
128
+
129
+ # Add .gitignore patterns
130
+ if include_gitignore and is_git_repository(root_path):
131
+ gitignore_patterns = read_gitignore_patterns(root_path)
132
+ for pattern in gitignore_patterns:
133
+ relative_pattern, pattern_root = parse_gitignore_pattern(pattern, root_path)
134
+ if pattern_root not in ignore_map:
135
+ ignore_map[pattern_root] = []
136
+ ignore_map[pattern_root].append(relative_pattern)
137
+
138
+ return ignore_map
139
+
140
+
141
+ def should_ignore_path(
142
+ path: Path, root_path: Path, ignore_map: Dict[Optional[Path], List[str]]
143
+ ) -> bool:
144
+ """Check if a path should be ignored based on ignore patterns."""
145
+ # Check against each root in the ignore map
146
+ for pattern_root, patterns in ignore_map.items():
147
+ # Determine the actual root to use for pattern matching
148
+ actual_root = pattern_root if pattern_root is not None else root_path
149
+
150
+ try:
151
+ # Get relative path from actual_root
152
+ rel_path = path.relative_to(actual_root).as_posix()
153
+ except ValueError:
154
+ # Path is not under this root, skip
155
+ continue
156
+
157
+ # For directories, also check with trailing slash
158
+ rel_path_dir = f"{rel_path}/" if path.is_dir() else rel_path
159
+
160
+ # Check each pattern
161
+ for pattern in patterns:
162
+ # Handle directory-specific patterns
163
+ if pattern.endswith("/"):
164
+ if not path.is_dir():
165
+ continue
166
+ pattern_without_slash = pattern[:-1]
167
+ if fnmatch.fnmatch(rel_path, pattern_without_slash) or fnmatch.fnmatch(
168
+ rel_path_dir, pattern
169
+ ):
170
+ return True
171
+ else:
172
+ if fnmatch.fnmatch(rel_path, pattern) or fnmatch.fnmatch(rel_path_dir, pattern):
173
+ return True
174
+
175
+ return False
176
+
177
+
178
+ def get_git_status_files(root_path: Path) -> Tuple[List[str], List[str]]:
179
+ """Get tracked and untracked files from git status."""
180
+ tracked: List[str] = []
181
+ untracked: List[str] = []
182
+
183
+ if not is_git_repository(root_path):
184
+ return tracked, untracked
185
+
186
+ try:
187
+ # Get tracked files (modified, added, etc.)
188
+ result = subprocess.run(
189
+ ["git", "status", "--porcelain"],
190
+ cwd=root_path,
191
+ capture_output=True,
192
+ text=True,
193
+ timeout=10,
194
+ )
195
+
196
+ if result.returncode == 0:
197
+ for line in result.stdout.strip().split("\n"):
198
+ if line:
199
+ status = line[:2].strip()
200
+ file_path = line[3:].strip()
201
+
202
+ # Remove quotes if present
203
+ if file_path.startswith('"') and file_path.endswith('"'):
204
+ file_path = file_path[1:-1]
205
+
206
+ if status == "??": # Untracked
207
+ untracked.append(file_path)
208
+ else: # Tracked (modified, added, etc.)
209
+ tracked.append(file_path)
210
+
211
+ except (subprocess.SubprocessError, FileNotFoundError):
212
+ pass
213
+
214
+ return tracked, untracked
215
+
216
+
217
+ def get_current_git_branch(root_path: Path) -> Optional[str]:
218
+ """Get the current git branch name."""
219
+ if not is_git_repository(root_path):
220
+ return None
221
+
222
+ try:
223
+ result = subprocess.run(
224
+ ["git", "branch", "--show-current"],
225
+ cwd=root_path,
226
+ capture_output=True,
227
+ text=True,
228
+ timeout=5,
229
+ )
230
+ if result.returncode == 0:
231
+ return result.stdout.strip()
232
+ except (subprocess.SubprocessError, FileNotFoundError):
233
+ pass
234
+
235
+ return None
236
+
237
+
238
+ def get_git_commit_hash(root_path: Path) -> Optional[str]:
239
+ """Get the current git commit hash."""
240
+ if not is_git_repository(root_path):
241
+ return None
242
+
243
+ try:
244
+ result = subprocess.run(
245
+ ["git", "rev-parse", "HEAD"],
246
+ cwd=root_path,
247
+ capture_output=True,
248
+ text=True,
249
+ timeout=5,
250
+ )
251
+ if result.returncode == 0:
252
+ return result.stdout.strip()[:8] # Short hash
253
+ except (subprocess.SubprocessError, FileNotFoundError):
254
+ pass
255
+
256
+ return None
257
+
258
+
259
+ def is_working_directory_clean(root_path: Path) -> bool:
260
+ """Check if the working directory is clean (no uncommitted changes)."""
261
+ if not is_git_repository(root_path):
262
+ return True
263
+
264
+ try:
265
+ result = subprocess.run(
266
+ ["git", "status", "--porcelain"],
267
+ cwd=root_path,
268
+ capture_output=True,
269
+ text=True,
270
+ timeout=5,
271
+ )
272
+ return result.returncode == 0 and not result.stdout.strip()
273
+ except (subprocess.SubprocessError, FileNotFoundError):
274
+ return True
@@ -0,0 +1,27 @@
1
+ """JSON helper utilities for Ripperdoc."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, Optional
7
+
8
+ from ripperdoc.utils.log import get_logger
9
+
10
+
11
+ logger = get_logger()
12
+
13
+
14
+ def safe_parse_json(json_text: Optional[str], log_error: bool = True) -> Optional[Any]:
15
+ """Best-effort JSON.parse wrapper that returns None on failure."""
16
+ if not json_text:
17
+ return None
18
+ try:
19
+ return json.loads(json_text)
20
+ except (json.JSONDecodeError, TypeError, ValueError) as exc:
21
+ if log_error:
22
+ logger.debug(
23
+ "[json_utils] Failed to parse JSON: %s: %s",
24
+ type(exc).__name__, exc,
25
+ extra={"length": len(json_text)},
26
+ )
27
+ return None
ripperdoc/utils/log.py ADDED
@@ -0,0 +1,176 @@
1
+ """Logging utilities for Ripperdoc."""
2
+
3
+ import json
4
+ import logging
5
+ import sys
6
+ import os
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from typing import Any, Optional
10
+
11
+ from ripperdoc.utils.path_utils import sanitize_project_path
12
+
13
+
14
+ _LOG_RECORD_FIELDS = {
15
+ "name",
16
+ "msg",
17
+ "args",
18
+ "levelname",
19
+ "levelno",
20
+ "pathname",
21
+ "filename",
22
+ "module",
23
+ "exc_info",
24
+ "exc_text",
25
+ "stack_info",
26
+ "lineno",
27
+ "funcName",
28
+ "created",
29
+ "msecs",
30
+ "relativeCreated",
31
+ "thread",
32
+ "threadName",
33
+ "processName",
34
+ "process",
35
+ "message",
36
+ "asctime",
37
+ "stacklevel",
38
+ }
39
+
40
+
41
+ class StructuredFormatter(logging.Formatter):
42
+ """Formatter with ISO timestamps and context."""
43
+
44
+ def formatTime(self, record: logging.LogRecord, datefmt: Optional[str] = None) -> str:
45
+ timestamp = datetime.utcfromtimestamp(record.created)
46
+ return timestamp.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
47
+
48
+ def format(self, record: logging.LogRecord) -> str:
49
+ message = super().format(record)
50
+ extras = {
51
+ key: value
52
+ for key, value in record.__dict__.items()
53
+ if key not in _LOG_RECORD_FIELDS and not key.startswith("_")
54
+ }
55
+ if extras:
56
+ try:
57
+ serialized = json.dumps(extras, sort_keys=True, ensure_ascii=True, default=str)
58
+ except (TypeError, ValueError):
59
+ serialized = str(extras)
60
+ return f"{message} | {serialized}"
61
+ return message
62
+
63
+
64
+ class RipperdocLogger:
65
+ """Logger for Ripperdoc."""
66
+
67
+ def __init__(self, name: str = "ripperdoc", log_dir: Optional[Path] = None):
68
+ self.logger = logging.getLogger(name)
69
+ level_name = os.getenv("RIPPERDOC_LOG_LEVEL", "WARNING").upper()
70
+ level = getattr(logging, level_name, logging.WARNING)
71
+ # Allow file handlers to capture debug logs while console respects the configured level.
72
+ self.logger.setLevel(logging.DEBUG)
73
+ self.logger.propagate = False
74
+
75
+ # Avoid adding duplicate handlers if an existing logger is reused.
76
+ if not self.logger.handlers:
77
+ console_handler = logging.StreamHandler(sys.stderr)
78
+ console_handler.setLevel(level)
79
+ console_formatter = logging.Formatter("%(levelname)s: %(message)s")
80
+ console_handler.setFormatter(console_formatter)
81
+ self.logger.addHandler(console_handler)
82
+
83
+ self._file_handler: Optional[logging.Handler] = None
84
+ self._file_handler_path: Optional[Path] = None
85
+
86
+ if log_dir:
87
+ log_dir.mkdir(parents=True, exist_ok=True)
88
+ log_file = log_dir / f"ripperdoc_{datetime.now().strftime('%Y%m%d')}.log"
89
+ self.attach_file_handler(log_file)
90
+
91
+ def attach_file_handler(self, log_file: Path) -> Path:
92
+ """Attach or replace a file handler for logging to disk."""
93
+ log_file.parent.mkdir(parents=True, exist_ok=True)
94
+ if self._file_handler and self._file_handler_path == log_file:
95
+ return log_file
96
+
97
+ if self._file_handler:
98
+ try:
99
+ self.logger.removeHandler(self._file_handler)
100
+ except (ValueError, RuntimeError):
101
+ # Swallow errors while rotating handlers; console logging should continue.
102
+ pass
103
+
104
+ # Use UTF-8 to avoid Windows code page encoding errors when logs contain non-ASCII text.
105
+ file_handler = logging.FileHandler(log_file, encoding="utf-8")
106
+ file_handler.setLevel(logging.DEBUG)
107
+ file_formatter = StructuredFormatter("%(asctime)s [%(levelname)s] %(message)s")
108
+ file_handler.setFormatter(file_formatter)
109
+ self.logger.addHandler(file_handler)
110
+ self._file_handler = file_handler
111
+ self._file_handler_path = log_file
112
+ return log_file
113
+
114
+ def debug(self, message: str, *args: Any, **kwargs: Any) -> None:
115
+ """Log debug message."""
116
+ self.logger.debug(message, *args, **kwargs)
117
+
118
+ def info(self, message: str, *args: Any, **kwargs: Any) -> None:
119
+ """Log info message."""
120
+ self.logger.info(message, *args, **kwargs)
121
+
122
+ def warning(self, message: str, *args: Any, **kwargs: Any) -> None:
123
+ """Log warning message."""
124
+ self.logger.warning(message, *args, **kwargs)
125
+
126
+ def error(self, message: str, *args: Any, **kwargs: Any) -> None:
127
+ """Log error message."""
128
+ self.logger.error(message, *args, **kwargs)
129
+
130
+ def critical(self, message: str, *args: Any, **kwargs: Any) -> None:
131
+ """Log critical message."""
132
+ self.logger.critical(message, *args, **kwargs)
133
+
134
+ def exception(self, message: str, *args: Any, **kwargs: Any) -> None:
135
+ """Log an exception with traceback."""
136
+ self.logger.exception(message, *args, **kwargs)
137
+
138
+
139
+ # Global logger instance
140
+ _logger: Optional[RipperdocLogger] = None
141
+
142
+
143
+ def get_logger() -> RipperdocLogger:
144
+ """Get the global logger instance."""
145
+ global _logger
146
+ if _logger is None:
147
+ _logger = RipperdocLogger()
148
+ return _logger
149
+
150
+
151
+ def _normalize_path_for_logs(project_path: Path) -> Path:
152
+ """Return the directory for log files for a given project."""
153
+ safe_name = sanitize_project_path(project_path)
154
+ return Path.home() / ".ripperdoc" / "logs" / safe_name
155
+
156
+
157
+ def session_log_path(project_path: Path, session_id: str, when: Optional[datetime] = None) -> Path:
158
+ """Build the log file path for a project session."""
159
+ timestamp = (when or datetime.now()).strftime("%Y%m%d-%H%M%S")
160
+ return _normalize_path_for_logs(project_path) / f"{timestamp}-{session_id}.log"
161
+
162
+
163
+ def init_logger(log_dir: Optional[Path] = None) -> RipperdocLogger:
164
+ """Initialize the global logger."""
165
+ global _logger
166
+ _logger = RipperdocLogger(log_dir=log_dir)
167
+ return _logger
168
+
169
+
170
+ def enable_session_file_logging(project_path: Path, session_id: str) -> Path:
171
+ """Ensure the global logger writes to the session-specific log file."""
172
+ logger = get_logger()
173
+ log_file = session_log_path(project_path, session_id)
174
+ logger.attach_file_handler(log_file)
175
+ logger.debug(f"[logging] File logging enabled at {log_file}")
176
+ return log_file