wcgw 5.5.4__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.
@@ -0,0 +1,137 @@
1
+ """
2
+ File with definitions of known source code file extensions.
3
+ Used to determine the appropriate context length for files.
4
+ Supports selecting between coding_max_tokens and noncoding_max_tokens
5
+ based on file extensions.
6
+ """
7
+ from typing import Dict, Optional, Set
8
+
9
+ # Set of file extensions considered to be source code
10
+ # Each extension should be listed without the dot (e.g., 'py' not '.py')
11
+ SOURCE_CODE_EXTENSIONS: Set[str] = {
12
+ # Python
13
+ 'py', 'pyx', 'pyi', 'pyw',
14
+
15
+ # JavaScript and TypeScript
16
+ 'js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs',
17
+
18
+ # Web
19
+ 'html', 'htm', 'xhtml', 'css', 'scss', 'sass', 'less',
20
+
21
+ # C and C++
22
+ 'c', 'h', 'cpp', 'cxx', 'cc', 'hpp', 'hxx', 'hh', 'inl',
23
+
24
+ # C#
25
+ 'cs', 'csx',
26
+
27
+ # Java
28
+ 'java', 'scala', 'kt', 'kts', 'groovy',
29
+
30
+ # Go
31
+ 'go', 'mod',
32
+
33
+ # Rust
34
+ 'rs', 'rlib',
35
+
36
+ # Swift
37
+ 'swift',
38
+
39
+ # Ruby
40
+ 'rb', 'rake', 'gemspec',
41
+
42
+ # PHP
43
+ 'php', 'phtml', 'phar', 'phps',
44
+
45
+ # Shell
46
+ 'sh', 'bash', 'zsh', 'fish',
47
+
48
+ # PowerShell
49
+ 'ps1', 'psm1', 'psd1',
50
+
51
+ # SQL
52
+ 'sql', 'ddl', 'dml',
53
+
54
+ # Markup and config
55
+ 'xml', 'json', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf',
56
+
57
+ # Documentation
58
+ 'md', 'markdown', 'rst', 'adoc', 'tex',
59
+
60
+ # Build and dependency files
61
+ 'Makefile', 'Dockerfile', 'Jenkinsfile',
62
+
63
+ # Haskell
64
+ 'hs', 'lhs',
65
+
66
+ # Lisp family
67
+ 'lisp', 'cl', 'el', 'clj', 'cljs', 'edn', 'scm',
68
+
69
+ # Erlang and Elixir
70
+ 'erl', 'hrl', 'ex', 'exs',
71
+
72
+ # Dart and Flutter
73
+ 'dart',
74
+
75
+ # Objective-C
76
+ 'm', 'mm',
77
+ }
78
+
79
+ # Context length limits based on file type (in tokens)
80
+ CONTEXT_LENGTH_LIMITS: Dict[str, int] = {
81
+ 'source_code': 24000, # For known source code files
82
+ 'default': 8000, # For all other files
83
+ }
84
+
85
+ def is_source_code_file(filename: str) -> bool:
86
+ """
87
+ Determine if a file is a source code file based on its extension.
88
+
89
+ Args:
90
+ filename: The name of the file to check
91
+
92
+ Returns:
93
+ True if the file has a recognized source code extension, False otherwise
94
+ """
95
+ # Extract extension (without the dot)
96
+ parts = filename.split('.')
97
+ if len(parts) > 1:
98
+ ext = parts[-1].lower()
99
+ return ext in SOURCE_CODE_EXTENSIONS
100
+
101
+ # Files without extensions (like 'Makefile', 'Dockerfile')
102
+ # Case-insensitive match for files without extensions
103
+ return filename.lower() in {ext.lower() for ext in SOURCE_CODE_EXTENSIONS}
104
+
105
+ def get_context_length_for_file(filename: str) -> int:
106
+ """
107
+ Get the appropriate context length limit for a file based on its extension.
108
+
109
+ Args:
110
+ filename: The name of the file to check
111
+
112
+ Returns:
113
+ The context length limit in tokens
114
+ """
115
+ if is_source_code_file(filename):
116
+ return CONTEXT_LENGTH_LIMITS['source_code']
117
+ return CONTEXT_LENGTH_LIMITS['default']
118
+
119
+
120
+ def select_max_tokens(filename: str, coding_max_tokens: Optional[int], noncoding_max_tokens: Optional[int]) -> Optional[int]:
121
+ """
122
+ Select the appropriate max_tokens limit based on file type.
123
+
124
+ Args:
125
+ filename: The name of the file to check
126
+ coding_max_tokens: Maximum tokens for source code files
127
+ noncoding_max_tokens: Maximum tokens for non-source code files
128
+
129
+ Returns:
130
+ The appropriate max_tokens limit for the file
131
+ """
132
+ if coding_max_tokens is None and noncoding_max_tokens is None:
133
+ return None
134
+
135
+ if is_source_code_file(filename):
136
+ return coding_max_tokens
137
+ return noncoding_max_tokens
@@ -0,0 +1,212 @@
1
+ import re
2
+ from typing import Callable, Optional
3
+
4
+ from .diff_edit import FileEditInput, FileEditOutput, SearchReplaceMatchError
5
+
6
+ # Global regex patterns
7
+ SEARCH_MARKER = re.compile(r"^<<<<<<+\s*SEARCH>?\s*$")
8
+ DIVIDER_MARKER = re.compile(r"^======*\s*$")
9
+ REPLACE_MARKER = re.compile(r"^>>>>>>+\s*REPLACE\s*$")
10
+
11
+
12
+ class SearchReplaceSyntaxError(Exception):
13
+ def __init__(self, message: str):
14
+ message = f"""Got syntax error while parsing search replace blocks:
15
+ {message}
16
+ ---
17
+
18
+ Make sure blocks are in correct sequence, and the markers are in separate lines:
19
+
20
+ <{"<<<<<< SEARCH"}
21
+ example old
22
+ =======
23
+ example new
24
+ >{">>>>>> REPLACE"}
25
+
26
+ """
27
+ super().__init__(message)
28
+
29
+
30
+ def search_replace_edit(
31
+ lines: list[str], original_content: str, logger: Callable[[str], object]
32
+ ) -> tuple[str, str]:
33
+ if not lines:
34
+ raise SearchReplaceSyntaxError("Error: No input to search replace edit")
35
+
36
+ original_lines = original_content.split("\n")
37
+ n_lines = len(lines)
38
+ i = 0
39
+ search_replace_blocks = list[tuple[list[str], list[str]]]()
40
+
41
+ while i < n_lines:
42
+ if SEARCH_MARKER.match(lines[i]):
43
+ line_num = i + 1
44
+ search_block = []
45
+ i += 1
46
+
47
+ while i < n_lines and not DIVIDER_MARKER.match(lines[i]):
48
+ if SEARCH_MARKER.match(lines[i]) or REPLACE_MARKER.match(lines[i]):
49
+ raise SearchReplaceSyntaxError(
50
+ f"Line {i + 1}: Found stray marker in SEARCH block: {lines[i]}"
51
+ )
52
+ search_block.append(lines[i])
53
+ i += 1
54
+
55
+ if i >= n_lines:
56
+ raise SearchReplaceSyntaxError(
57
+ f"Line {line_num}: Unclosed SEARCH block - missing ======= marker"
58
+ )
59
+
60
+ if not search_block:
61
+ raise SearchReplaceSyntaxError(
62
+ f"Line {line_num}: SEARCH block cannot be empty"
63
+ )
64
+
65
+ i += 1
66
+ replace_block = []
67
+
68
+ while i < n_lines and not REPLACE_MARKER.match(lines[i]):
69
+ if SEARCH_MARKER.match(lines[i]) or DIVIDER_MARKER.match(lines[i]):
70
+ raise SearchReplaceSyntaxError(
71
+ f"Line {i + 1}: Found stray marker in REPLACE block: {lines[i]}"
72
+ )
73
+ replace_block.append(lines[i])
74
+ i += 1
75
+
76
+ if i >= n_lines:
77
+ raise SearchReplaceSyntaxError(
78
+ f"Line {line_num}: Unclosed block - missing REPLACE marker"
79
+ )
80
+
81
+ i += 1
82
+
83
+ for line in search_block:
84
+ logger("> " + line)
85
+ logger("=======")
86
+ for line in replace_block:
87
+ logger("< " + line)
88
+ logger("\n\n\n\n")
89
+
90
+ search_replace_blocks.append((search_block, replace_block))
91
+ else:
92
+ if REPLACE_MARKER.match(lines[i]) or DIVIDER_MARKER.match(lines[i]):
93
+ raise SearchReplaceSyntaxError(
94
+ f"Line {i + 1}: Found stray marker outside block: {lines[i]}"
95
+ )
96
+ i += 1
97
+
98
+ if not search_replace_blocks:
99
+ raise SearchReplaceSyntaxError(
100
+ "No valid search replace blocks found, ensure your SEARCH/REPLACE blocks are formatted correctly"
101
+ )
102
+
103
+ edited_content, comments_ = edit_with_individual_fallback(
104
+ original_lines, search_replace_blocks, False
105
+ )
106
+
107
+ edited_file = "\n".join(edited_content)
108
+ if not comments_:
109
+ comments = "File edited successfully."
110
+ else:
111
+ comments = (
112
+ "File edited successfully. However, following warnings were generated while matching search blocks.\n"
113
+ + "\n".join(comments_)
114
+ )
115
+ return edited_file, comments
116
+
117
+
118
+ def identify_first_differing_block(
119
+ best_matches: list[FileEditOutput],
120
+ ) -> Optional[list[str]]:
121
+ """
122
+ Identify the first search block that differs across multiple best matches.
123
+ Returns the search block content that first shows different matches.
124
+ """
125
+ if not best_matches or len(best_matches) <= 1:
126
+ return None
127
+
128
+ # First, check if the number of blocks differs (shouldn't happen, but let's be safe)
129
+ block_counts = [len(match.edited_with_tolerances) for match in best_matches]
130
+ if not all(count == block_counts[0] for count in block_counts):
131
+ # If block counts differ, just return the first search block as problematic
132
+ return (
133
+ best_matches[0].orig_search_blocks[0]
134
+ if best_matches[0].orig_search_blocks
135
+ else None
136
+ )
137
+
138
+ # Go through each block position and see if the slices differ
139
+ for i in range(min(block_counts)):
140
+ slices = [match.edited_with_tolerances[i][0] for match in best_matches]
141
+
142
+ # Check if we have different slices for this block across matches
143
+ if any(s.start != slices[0].start or s.stop != slices[0].stop for s in slices):
144
+ # We found our differing block - return the search block content
145
+ if i < len(best_matches[0].orig_search_blocks):
146
+ return best_matches[0].orig_search_blocks[i]
147
+ else:
148
+ return None
149
+
150
+ # If we get here, we couldn't identify a specific differing block
151
+ return None
152
+
153
+
154
+ def edit_with_individual_fallback(
155
+ original_lines: list[str],
156
+ search_replace_blocks: list[tuple[list[str], list[str]]],
157
+ replace_all: bool,
158
+ ) -> tuple[list[str], set[str]]:
159
+ outputs = FileEditInput(original_lines, 0, search_replace_blocks, 0).edit_file()
160
+ best_matches = FileEditOutput.get_best_match(outputs)
161
+ try:
162
+ edited_content, comments_ = best_matches[0].replace_or_throw(3)
163
+ except SearchReplaceMatchError:
164
+ if len(search_replace_blocks) > 1:
165
+ try:
166
+ # Try one at a time
167
+ all_comments = set[str]()
168
+ running_lines = list(original_lines)
169
+ for block in search_replace_blocks:
170
+ running_lines, comments_ = edit_with_individual_fallback(
171
+ running_lines, [block], replace_all
172
+ )
173
+ all_comments |= comments_
174
+ return running_lines, all_comments
175
+ except SearchReplaceMatchError:
176
+ # Raise the outer error instead
177
+ # Otherwise the suggested search block will be
178
+ # after applying previous N search blocks and that
179
+ # would signal to LLM that we've updated the file
180
+ pass
181
+ raise
182
+
183
+ if replace_all and len(best_matches) > 1 and len(search_replace_blocks) == 1:
184
+ # For only one search/replace block only replace all
185
+ try:
186
+ edited_content, comments__ = edit_with_individual_fallback(
187
+ edited_content, search_replace_blocks, replace_all
188
+ )
189
+ comments_ |= comments__
190
+ except SearchReplaceMatchError:
191
+ # Will not happen ideally, but still no use of throwing error here
192
+ pass
193
+ elif len(best_matches) > 1:
194
+ # Find the first block that differs across matches
195
+ first_diff_block = identify_first_differing_block(best_matches)
196
+ if first_diff_block is not None:
197
+ block_content = "\n".join(first_diff_block)
198
+ raise SearchReplaceMatchError(f"""
199
+ The following block matched more than once:
200
+ ```
201
+ {block_content}
202
+ ```
203
+ Consider adding more context before and after this block to make the match unique.
204
+ """)
205
+ else:
206
+ raise SearchReplaceMatchError("""
207
+ One of the blocks matched more than once
208
+
209
+ Consider adding more context before and after all the blocks to make the match unique.
210
+ """)
211
+
212
+ return edited_content, comments_
@@ -0,0 +1,3 @@
1
+ # The doc has moved to main Readme.md
2
+
3
+ ![main readme](https://github.com/rusiaaman/wcgw/blob/main/README.md)
@@ -0,0 +1,32 @@
1
+ # mypy: disable-error-code="import-untyped"
2
+ import asyncio
3
+ import importlib
4
+
5
+ import typer
6
+ from typer import Typer
7
+
8
+ from wcgw.client.mcp_server import server
9
+
10
+ main = Typer()
11
+
12
+
13
+ @main.command()
14
+ def app(
15
+ version: bool = typer.Option(
16
+ False, "--version", "-v", help="Show version and exit"
17
+ ),
18
+ shell: str = typer.Option(
19
+ "", "--shell", help="Path to shell executable (defaults to $SHELL or /bin/bash)"
20
+ ),
21
+ ) -> None:
22
+ """Main entry point for the package."""
23
+ if version:
24
+ version_ = importlib.metadata.version("wcgw")
25
+ print(f"wcgw version: {version_}")
26
+ raise typer.Exit()
27
+
28
+ asyncio.run(server.main(shell))
29
+
30
+
31
+ # Optionally expose other important items at package level
32
+ __all__ = ["main", "server"]
@@ -0,0 +1,184 @@
1
+ import importlib
2
+ import logging
3
+ import os
4
+ from typing import Any, Optional
5
+
6
+ import mcp.server.stdio
7
+ import mcp.types as types
8
+ from mcp.server import NotificationOptions, Server
9
+ from mcp.server.models import InitializationOptions
10
+ from pydantic import AnyUrl
11
+
12
+ from wcgw.client.modes import KTS
13
+ from wcgw.client.tool_prompts import TOOL_PROMPTS
14
+
15
+ from ...types_ import (
16
+ Initialize,
17
+ )
18
+ from ..bash_state.bash_state import CONFIG, BashState, get_tmpdir
19
+ from ..tools import (
20
+ Context,
21
+ default_enc,
22
+ get_tool_output,
23
+ parse_tool_by_name,
24
+ which_tool_name,
25
+ )
26
+
27
+ server: Server[Any] = Server("wcgw")
28
+
29
+ # Log only time stamp
30
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s: %(message)s")
31
+ logger = logging.getLogger("wcgw")
32
+
33
+
34
+ class Console:
35
+ def print(self, msg: str, *args: Any, **kwargs: Any) -> None:
36
+ logger.info(msg)
37
+
38
+ def log(self, msg: str, *args: Any, **kwargs: Any) -> None:
39
+ logger.info(msg)
40
+
41
+
42
+ @server.list_resources() # type: ignore
43
+ async def handle_list_resources() -> list[types.Resource]:
44
+ return []
45
+
46
+
47
+ @server.read_resource() # type: ignore
48
+ async def handle_read_resource(uri: AnyUrl) -> str:
49
+ raise ValueError("No resources available")
50
+
51
+
52
+ PROMPTS = {
53
+ "KnowledgeTransfer": (
54
+ types.Prompt(
55
+ name="KnowledgeTransfer",
56
+ description="Prompt for invoking ContextSave tool in order to do a comprehensive knowledge transfer of a coding task. Prompts to save detailed error log and instructions.",
57
+ ),
58
+ KTS,
59
+ )
60
+ }
61
+
62
+
63
+ @server.list_prompts() # type: ignore
64
+ async def handle_list_prompts() -> list[types.Prompt]:
65
+ return [x[0] for x in PROMPTS.values()]
66
+
67
+
68
+ @server.get_prompt() # type: ignore
69
+ async def handle_get_prompt(
70
+ name: str, arguments: dict[str, str] | None
71
+ ) -> types.GetPromptResult:
72
+ assert BASH_STATE
73
+ messages = [
74
+ types.PromptMessage(
75
+ role="user",
76
+ content=types.TextContent(
77
+ type="text", text=PROMPTS[name][1][BASH_STATE.mode]
78
+ ),
79
+ )
80
+ ]
81
+ return types.GetPromptResult(messages=messages)
82
+
83
+
84
+ @server.list_tools() # type: ignore
85
+ async def handle_list_tools() -> list[types.Tool]:
86
+ """
87
+ List available tools.
88
+ Each tool specifies its arguments using JSON Schema validation.
89
+ """
90
+
91
+ return TOOL_PROMPTS
92
+
93
+
94
+ @server.call_tool() # type: ignore
95
+ async def handle_call_tool(
96
+ name: str, arguments: dict[str, Any] | None
97
+ ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
98
+ global BASH_STATE
99
+ if not arguments:
100
+ raise ValueError("Missing arguments")
101
+
102
+ tool_type = which_tool_name(name)
103
+ tool_call = parse_tool_by_name(name, arguments)
104
+
105
+ try:
106
+ assert BASH_STATE
107
+ output_or_dones, _ = get_tool_output(
108
+ Context(BASH_STATE, BASH_STATE.console),
109
+ tool_call,
110
+ default_enc,
111
+ 0.0,
112
+ lambda x, y: ("", 0),
113
+ 24000, # coding_max_tokens
114
+ 8000, # noncoding_max_tokens
115
+ )
116
+
117
+ except Exception as e:
118
+ output_or_dones = [f"GOT EXCEPTION while calling tool. Error: {e}"]
119
+
120
+ content: list[types.TextContent | types.ImageContent | types.EmbeddedResource] = []
121
+ for output_or_done in output_or_dones:
122
+ if isinstance(output_or_done, str):
123
+ if issubclass(tool_type, Initialize):
124
+ # Prepare the original hardcoded message
125
+ original_message = """
126
+ - Additional important note: as soon as you encounter "The user has chosen to disallow the tool call.", immediately stop doing everything and ask user for the reason.
127
+
128
+ Initialize call done.
129
+ """
130
+
131
+ # If custom instructions exist, prepend them to the original message
132
+ if CUSTOM_INSTRUCTIONS:
133
+ output_or_done += f"\n{CUSTOM_INSTRUCTIONS}\n{original_message}"
134
+ else:
135
+ output_or_done += original_message
136
+
137
+ content.append(types.TextContent(type="text", text=output_or_done))
138
+ else:
139
+ content.append(
140
+ types.ImageContent(
141
+ type="image",
142
+ data=output_or_done.data,
143
+ mimeType=output_or_done.media_type,
144
+ )
145
+ )
146
+
147
+ return content
148
+
149
+
150
+ BASH_STATE = None
151
+ CUSTOM_INSTRUCTIONS = None
152
+
153
+
154
+ async def main(shell_path: str = "") -> None:
155
+ global BASH_STATE, CUSTOM_INSTRUCTIONS
156
+ CONFIG.update(3, 55, 5)
157
+ version = str(importlib.metadata.version("wcgw"))
158
+
159
+ # Read custom instructions from environment variable
160
+ CUSTOM_INSTRUCTIONS = os.getenv("WCGW_SERVER_INSTRUCTIONS")
161
+
162
+ # starting_dir is inside tmp dir
163
+ tmp_dir = get_tmpdir()
164
+ starting_dir = os.path.join(tmp_dir, "claude_playground")
165
+
166
+ with BashState(
167
+ Console(), starting_dir, None, None, None, None, True, None, None, shell_path or None
168
+ ) as BASH_STATE:
169
+ BASH_STATE.console.log("wcgw version: " + version)
170
+ # Run the server using stdin/stdout streams
171
+ async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
172
+ await server.run(
173
+ read_stream,
174
+ write_stream,
175
+ InitializationOptions(
176
+ server_name="wcgw",
177
+ server_version=version,
178
+ capabilities=server.get_capabilities(
179
+ notification_options=NotificationOptions(),
180
+ experimental_capabilities={},
181
+ ),
182
+ ),
183
+ raise_exceptions=False,
184
+ )
wcgw/client/memory.py ADDED
@@ -0,0 +1,103 @@
1
+ import json
2
+ import os
3
+ import re
4
+ import shlex
5
+ from typing import Any, Callable, Optional, TypeVar
6
+
7
+ from ..types_ import ContextSave
8
+
9
+
10
+ def get_app_dir_xdg() -> str:
11
+ xdg_data_dir = os.environ.get("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
12
+ return os.path.join(xdg_data_dir, "wcgw")
13
+
14
+
15
+ def format_memory(task_memory: ContextSave, relevant_files: str) -> str:
16
+ memory_data = ""
17
+ if task_memory.project_root_path:
18
+ memory_data += (
19
+ f"# PROJECT ROOT = {shlex.quote(task_memory.project_root_path)}\n"
20
+ )
21
+ memory_data += task_memory.description
22
+
23
+ memory_data += (
24
+ "\n\n"
25
+ + "# Relevant file paths\n"
26
+ + ", ".join(map(shlex.quote, task_memory.relevant_file_globs))
27
+ )
28
+
29
+ memory_data += "\n\n# Relevant Files:\n" + relevant_files
30
+
31
+ return memory_data
32
+
33
+
34
+ def save_memory(
35
+ task_memory: ContextSave,
36
+ relevant_files: str,
37
+ bash_state_dict: Optional[dict[str, Any]] = None,
38
+ ) -> str:
39
+ app_dir = get_app_dir_xdg()
40
+ memory_dir = os.path.join(app_dir, "memory")
41
+ os.makedirs(memory_dir, exist_ok=True)
42
+
43
+ task_id = task_memory.id
44
+ if not task_id:
45
+ raise Exception("Task id can not be empty")
46
+ memory_data = format_memory(task_memory, relevant_files)
47
+
48
+ memory_file_full = os.path.join(memory_dir, f"{task_id}.txt")
49
+
50
+ with open(memory_file_full, "w") as f:
51
+ f.write(memory_data)
52
+
53
+ # Save bash state if provided
54
+ if bash_state_dict is not None:
55
+ state_file = os.path.join(memory_dir, f"{task_id}_bash_state.json")
56
+ with open(state_file, "w") as f:
57
+ json.dump(bash_state_dict, f, indent=2)
58
+
59
+ return memory_file_full
60
+
61
+
62
+ T = TypeVar("T")
63
+
64
+
65
+ def load_memory(
66
+ task_id: str,
67
+ coding_max_tokens: Optional[int],
68
+ noncoding_max_tokens: Optional[int],
69
+ encoder: Callable[[str], list[T]],
70
+ decoder: Callable[[list[T]], str],
71
+ ) -> tuple[str, str, Optional[dict[str, Any]]]:
72
+ app_dir = get_app_dir_xdg()
73
+ memory_dir = os.path.join(app_dir, "memory")
74
+ memory_file = os.path.join(memory_dir, f"{task_id}.txt")
75
+
76
+ with open(memory_file, "r") as f:
77
+ data = f.read()
78
+
79
+ # Memory files are considered non-code files for token limits
80
+ max_tokens = noncoding_max_tokens
81
+ if max_tokens:
82
+ toks = encoder(data)
83
+ if len(toks) > max_tokens:
84
+ toks = toks[: max(0, max_tokens - 10)]
85
+ data = decoder(toks)
86
+ data += "\n(... truncated)"
87
+
88
+ project_root_match = re.search(r"# PROJECT ROOT = \s*(.*?)\s*$", data, re.MULTILINE)
89
+ project_root_path = ""
90
+ if project_root_match:
91
+ matched_path = project_root_match.group(1)
92
+ parsed_ = shlex.split(matched_path)
93
+ if parsed_ and len(parsed_) == 1:
94
+ project_root_path = parsed_[0]
95
+
96
+ # Try to load bash state if exists
97
+ state_file = os.path.join(memory_dir, f"{task_id}_bash_state.json")
98
+ bash_state: Optional[dict[str, Any]] = None
99
+ if os.path.exists(state_file):
100
+ with open(state_file) as f:
101
+ bash_state = json.load(f)
102
+
103
+ return project_root_path, data, bash_state