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.
- wcgw/__init__.py +4 -0
- wcgw/client/__init__.py +0 -0
- wcgw/client/bash_state/bash_state.py +1426 -0
- wcgw/client/bash_state/parser/__init__.py +7 -0
- wcgw/client/bash_state/parser/bash_statement_parser.py +181 -0
- wcgw/client/common.py +51 -0
- wcgw/client/diff-instructions.txt +73 -0
- wcgw/client/encoder/__init__.py +47 -0
- wcgw/client/file_ops/diff_edit.py +619 -0
- wcgw/client/file_ops/extensions.py +137 -0
- wcgw/client/file_ops/search_replace.py +212 -0
- wcgw/client/mcp_server/Readme.md +3 -0
- wcgw/client/mcp_server/__init__.py +32 -0
- wcgw/client/mcp_server/server.py +184 -0
- wcgw/client/memory.py +103 -0
- wcgw/client/modes.py +240 -0
- wcgw/client/repo_ops/display_tree.py +116 -0
- wcgw/client/repo_ops/file_stats.py +152 -0
- wcgw/client/repo_ops/path_prob.py +58 -0
- wcgw/client/repo_ops/paths_model.vocab +20000 -0
- wcgw/client/repo_ops/paths_tokens.model +80042 -0
- wcgw/client/repo_ops/repo_context.py +289 -0
- wcgw/client/schema_generator.py +63 -0
- wcgw/client/tool_prompts.py +98 -0
- wcgw/client/tools.py +1432 -0
- wcgw/py.typed +0 -0
- wcgw/types_.py +318 -0
- wcgw-5.5.4.dist-info/METADATA +339 -0
- wcgw-5.5.4.dist-info/RECORD +38 -0
- wcgw-5.5.4.dist-info/WHEEL +4 -0
- wcgw-5.5.4.dist-info/entry_points.txt +4 -0
- wcgw-5.5.4.dist-info/licenses/LICENSE +213 -0
- wcgw_cli/__init__.py +1 -0
- wcgw_cli/__main__.py +3 -0
- wcgw_cli/anthropic_client.py +486 -0
- wcgw_cli/cli.py +40 -0
- wcgw_cli/openai_client.py +404 -0
- wcgw_cli/openai_utils.py +67 -0
|
@@ -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,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
|