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,289 @@
1
+ import os
2
+ from collections import deque
3
+ from pathlib import Path # Still needed for other parts
4
+ from typing import Optional
5
+
6
+ from pygit2 import GitError, Repository
7
+ from pygit2.enums import SortMode
8
+
9
+ from .display_tree import DirectoryTree
10
+ from .file_stats import load_workspace_stats
11
+ from .path_prob import FastPathAnalyzer
12
+
13
+ curr_folder = Path(__file__).parent
14
+ vocab_file = curr_folder / "paths_model.vocab"
15
+ model_file = curr_folder / "paths_tokens.model"
16
+ PATH_SCORER = FastPathAnalyzer(str(model_file), str(vocab_file))
17
+
18
+
19
+ def find_ancestor_with_git(path: Path) -> Optional[Repository]:
20
+ if path.is_file():
21
+ path = path.parent
22
+
23
+ try:
24
+ return Repository(str(path))
25
+ except GitError:
26
+ return None
27
+
28
+
29
+ MAX_ENTRIES_CHECK = 100_000
30
+
31
+
32
+ def get_all_files_max_depth(
33
+ abs_folder: str,
34
+ max_depth: int,
35
+ repo: Optional[Repository],
36
+ ) -> list[str]:
37
+ """BFS implementation using deque that maintains relative paths during traversal.
38
+ Returns (files_list, total_files_found) to track file count."""
39
+ all_files = []
40
+ # Queue stores: (folder_path, depth, rel_path_prefix)
41
+ queue = deque([(abs_folder, 0, "")])
42
+ entries_check = 0
43
+ while queue and entries_check < MAX_ENTRIES_CHECK:
44
+ current_folder, depth, prefix = queue.popleft()
45
+
46
+ if depth > max_depth:
47
+ continue
48
+
49
+ try:
50
+ entries = list(os.scandir(current_folder))
51
+ except PermissionError:
52
+ continue
53
+ except OSError:
54
+ continue
55
+ # Split into files and folders with single scan
56
+ files = []
57
+ folders = []
58
+ for entry in entries:
59
+ entries_check += 1
60
+ try:
61
+ is_file = entry.is_file(follow_symlinks=False)
62
+ except OSError:
63
+ continue
64
+ name = entry.name
65
+ rel_path = f"{prefix}{name}" if prefix else name
66
+
67
+ if repo and repo.path_is_ignored(rel_path):
68
+ continue
69
+
70
+ if is_file:
71
+ files.append(rel_path)
72
+ else:
73
+ folders.append((entry.path, rel_path))
74
+
75
+ # Process files first (maintain priority)
76
+ chunk = files[: min(10_000, max(0, MAX_ENTRIES_CHECK - entries_check))]
77
+ all_files.extend(chunk)
78
+
79
+ # Add folders to queue for BFS traversal
80
+ for folder_path, folder_rel_path in folders:
81
+ next_prefix = f"{folder_rel_path}/"
82
+ queue.append((folder_path, depth + 1, next_prefix))
83
+
84
+ return all_files
85
+
86
+
87
+ def get_recent_git_files(repo: Repository, count: int = 10) -> list[str]:
88
+ """
89
+ Get the most recently modified files from git history
90
+
91
+ Args:
92
+ repo: The git repository
93
+ count: Number of recent files to return
94
+
95
+ Returns:
96
+ List of relative paths to recently modified files
97
+ """
98
+ # Track seen files to avoid duplicates
99
+ seen_files: set[str] = set()
100
+ recent_files: list[str] = []
101
+
102
+ try:
103
+ # Get the HEAD reference and walk through recent commits
104
+ head = repo.head
105
+ for commit in repo.walk(head.target, SortMode.TOPOLOGICAL | SortMode.TIME):
106
+ # Skip merge commits which have multiple parents
107
+ if len(commit.parents) > 1:
108
+ continue
109
+
110
+ # If we have a parent, get the diff between the commit and its parent
111
+ if commit.parents:
112
+ parent = commit.parents[0]
113
+ diff = repo.diff(parent, commit) # type: ignore[attr-defined]
114
+ else:
115
+ # For the first commit, get the diff against an empty tree
116
+ diff = commit.tree.diff_to_tree(context_lines=0)
117
+
118
+ # Process each changed file in the diff
119
+ for patch in diff:
120
+ file_path = patch.delta.new_file.path
121
+
122
+ # Skip if we've already seen this file or if the file was deleted
123
+ repo_path_parent = Path(repo.path).parent
124
+ if (
125
+ file_path in seen_files
126
+ or not (repo_path_parent / file_path).exists()
127
+ ):
128
+ continue
129
+
130
+ seen_files.add(file_path)
131
+ recent_files.append(file_path)
132
+
133
+ # If we have enough files, stop
134
+ if len(recent_files) >= count:
135
+ return recent_files
136
+
137
+ except Exception:
138
+ # Handle git errors gracefully
139
+ pass
140
+
141
+ return recent_files
142
+
143
+
144
+ def calculate_dynamic_file_limit(total_files: int) -> int:
145
+ # Scale linearly, with minimum and maximum bounds
146
+ min_files = 50
147
+ max_files = 400
148
+
149
+ if total_files <= min_files:
150
+ return min_files
151
+
152
+ scale_factor = (max_files - min_files) / (30000 - min_files)
153
+
154
+ dynamic_limit = min_files + int((total_files - min_files) * scale_factor)
155
+
156
+ return min(max_files, dynamic_limit)
157
+
158
+
159
+ def get_repo_context(file_or_repo_path: str) -> tuple[str, Path]:
160
+ file_or_repo_path_ = Path(file_or_repo_path).absolute()
161
+
162
+ repo = find_ancestor_with_git(file_or_repo_path_)
163
+ recent_git_files: list[str] = []
164
+
165
+ # Determine the context directory
166
+ if repo is not None:
167
+ context_dir = Path(repo.path).parent
168
+ else:
169
+ if file_or_repo_path_.is_file():
170
+ context_dir = file_or_repo_path_.parent
171
+ else:
172
+ context_dir = file_or_repo_path_
173
+
174
+ # Load workspace stats from the context directory
175
+ workspace_stats = load_workspace_stats(str(context_dir))
176
+
177
+ # Get all files and calculate dynamic max files limit once
178
+ all_files = get_all_files_max_depth(str(context_dir), 10, repo)
179
+
180
+ # For Git repositories, get recent files
181
+ if repo is not None:
182
+ dynamic_max_files = calculate_dynamic_file_limit(len(all_files))
183
+ # Get recent git files - get at least 10 or 20% of dynamic_max_files, whichever is larger
184
+ recent_files_count = max(10, int(dynamic_max_files * 0.2))
185
+ recent_git_files = get_recent_git_files(repo, recent_files_count)
186
+ else:
187
+ # We don't want dynamic limit for non git folders like /tmp or ~
188
+ dynamic_max_files = 50
189
+
190
+ # Calculate probabilities in batch
191
+ path_scores = PATH_SCORER.calculate_path_probabilities_batch(all_files)
192
+
193
+ # Create list of (path, score) tuples and sort by score
194
+ path_with_scores = list(zip(all_files, (score[0] for score in path_scores)))
195
+ sorted_files = [
196
+ path for path, _ in sorted(path_with_scores, key=lambda x: x[1], reverse=True)
197
+ ]
198
+
199
+ # Start with recent git files, then add other important files
200
+ top_files = []
201
+
202
+ # If we have workspace stats, prioritize the most active files first
203
+ active_files = []
204
+ if workspace_stats is not None:
205
+ # Get files with activity score (weighted count of operations)
206
+ scored_files = []
207
+ for file_path, file_stats in workspace_stats.files.items():
208
+ try:
209
+ # Convert to relative path if possible
210
+ if str(context_dir) in file_path:
211
+ rel_path = os.path.relpath(file_path, str(context_dir))
212
+ else:
213
+ rel_path = file_path
214
+
215
+ # Calculate activity score - weight reads more for this functionality
216
+ activity_score = (
217
+ file_stats.read_count * 2
218
+ + (file_stats.edit_count)
219
+ + (file_stats.write_count)
220
+ )
221
+
222
+ # Only include files that still exist
223
+ if rel_path in all_files or os.path.exists(file_path):
224
+ scored_files.append((rel_path, activity_score))
225
+ except (ValueError, OSError):
226
+ # Skip files that cause path resolution errors
227
+ continue
228
+
229
+ # Sort by activity score (highest first) and get top 5
230
+ active_files = [
231
+ f for f, _ in sorted(scored_files, key=lambda x: x[1], reverse=True)[:5]
232
+ ]
233
+
234
+ # Add active files first
235
+ for file in active_files:
236
+ if file not in top_files and file in all_files:
237
+ top_files.append(file)
238
+
239
+ # Add recent git files next - these should be prioritized
240
+ for file in recent_git_files:
241
+ if file not in top_files and file in all_files:
242
+ top_files.append(file)
243
+
244
+ # Use statistical sorting for the remaining files, but respect dynamic_max_files limit
245
+ # and ensure we don't add duplicates
246
+ if len(top_files) < dynamic_max_files:
247
+ # Only add statistically important files that aren't already in top_files
248
+ for file in sorted_files:
249
+ if file not in top_files and len(top_files) < dynamic_max_files:
250
+ top_files.append(file)
251
+
252
+ directory_printer = DirectoryTree(context_dir, max_files=dynamic_max_files)
253
+ for file in top_files[:dynamic_max_files]:
254
+ directory_printer.expand(file)
255
+
256
+ return directory_printer.display(), context_dir
257
+
258
+
259
+ if __name__ == "__main__":
260
+ import cProfile
261
+ import pstats
262
+ import sys
263
+
264
+ from line_profiler import LineProfiler
265
+
266
+ folder = sys.argv[1]
267
+
268
+ # Profile using cProfile for overall function statistics
269
+ profiler = cProfile.Profile()
270
+ profiler.enable()
271
+ result = get_repo_context(folder)[0]
272
+ profiler.disable()
273
+
274
+ # Print cProfile stats
275
+ stats = pstats.Stats(profiler)
276
+ stats.sort_stats("cumulative")
277
+ print("\n=== Function-level profiling ===")
278
+ stats.print_stats(20) # Print top 20 functions
279
+
280
+ # Profile using line_profiler for line-by-line statistics
281
+ lp = LineProfiler()
282
+ lp_wrapper = lp(get_repo_context)
283
+ lp_wrapper(folder)
284
+
285
+ print("\n=== Line-by-line profiling ===")
286
+ lp.print_stats()
287
+
288
+ print("\n=== Result ===")
289
+ print(result)
@@ -0,0 +1,63 @@
1
+ """
2
+ Custom JSON schema generator to remove title fields from Pydantic models.
3
+
4
+ This module provides utilities to remove auto-generated title fields from JSON schemas,
5
+ making them more suitable for tool schemas where titles are not needed.
6
+ """
7
+
8
+ import copy
9
+ from typing import Any, Dict
10
+
11
+
12
+ def recursive_purge_dict_key(d: Dict[str, Any], k: str) -> None:
13
+ """
14
+ Remove a key from a dictionary recursively, but only from JSON schema metadata.
15
+
16
+ This function removes the specified key from dictionaries that appear to be
17
+ JSON schema objects (have "type" or "$ref" or are property definitions).
18
+ This prevents removing legitimate data fields that happen to have the same name.
19
+
20
+ Args:
21
+ d: The dictionary to clean
22
+ k: The key to remove (typically "title")
23
+ """
24
+ if isinstance(d, dict):
25
+ # Only remove the key if this looks like a JSON schema object
26
+ # This includes objects with "type", "$ref", or if we're in a "properties" context
27
+ is_schema_object = (
28
+ "type" in d or
29
+ "$ref" in d or
30
+ any(schema_key in d for schema_key in ["properties", "items", "additionalProperties", "enum", "const", "anyOf", "allOf", "oneOf"])
31
+ )
32
+
33
+ if is_schema_object and k in d:
34
+ del d[k]
35
+
36
+ # Recursively process all values, regardless of key names
37
+ # This ensures we catch all nested structures
38
+ for key, value in d.items():
39
+ if isinstance(value, dict):
40
+ recursive_purge_dict_key(value, k)
41
+ elif isinstance(value, list):
42
+ for item in value:
43
+ if isinstance(item, dict):
44
+ recursive_purge_dict_key(item, k)
45
+
46
+
47
+ def remove_titles_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
48
+ """
49
+ Remove all 'title' keys from a JSON schema dictionary.
50
+
51
+ This function creates a copy of the schema and removes all title keys
52
+ recursively, making it suitable for use with APIs that don't need titles.
53
+
54
+ Args:
55
+ schema: The JSON schema dictionary to clean
56
+
57
+ Returns:
58
+ A new dictionary with all title keys removed
59
+ """
60
+
61
+ schema_copy = copy.deepcopy(schema)
62
+ recursive_purge_dict_key(schema_copy, "title")
63
+ return schema_copy
@@ -0,0 +1,98 @@
1
+ import os
2
+
3
+ from mcp.types import Tool, ToolAnnotations
4
+
5
+ from ..types_ import (
6
+ BashCommand,
7
+ ContextSave,
8
+ FileWriteOrEdit,
9
+ Initialize,
10
+ ReadFiles,
11
+ ReadImage,
12
+ )
13
+ from .schema_generator import remove_titles_from_schema
14
+
15
+ with open(os.path.join(os.path.dirname(__file__), "diff-instructions.txt")) as f:
16
+ diffinstructions = f.read()
17
+
18
+
19
+ TOOL_PROMPTS = [
20
+ Tool(
21
+ inputSchema=remove_titles_from_schema(Initialize.model_json_schema()),
22
+ name="Initialize",
23
+ description="""
24
+ - Always call this at the start of the conversation before using any of the shell tools from wcgw.
25
+ - Use `any_workspace_path` to initialize the shell in the appropriate project directory.
26
+ - If the user has mentioned a workspace or project root or any other file or folder use it to set `any_workspace_path`.
27
+ - If user has mentioned any files use `initial_files_to_read` to read, use absolute paths only (~ allowed)
28
+ - By default use mode "wcgw"
29
+ - In "code-writer" mode, set the commands and globs which user asked to set, otherwise use 'all'.
30
+ - Use type="first_call" if it's the first call to this tool.
31
+ - Use type="user_asked_mode_change" if in a conversation user has asked to change mode.
32
+ - Use type="reset_shell" if in a conversation shell is not working after multiple tries.
33
+ - Use type="user_asked_change_workspace" if in a conversation user asked to change workspace
34
+ """,
35
+ annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False),
36
+ ),
37
+ Tool(
38
+ inputSchema=remove_titles_from_schema(BashCommand.model_json_schema()),
39
+ name="BashCommand",
40
+ description="""
41
+ - Execute a bash command. This is stateful (beware with subsequent calls).
42
+ - Status of the command and the current working directory will always be returned at the end.
43
+ - The first or the last line might be `(...truncated)` if the output is too long.
44
+ - Always run `pwd` if you get any file or directory not found error to make sure you're not lost.
45
+ - Do not run bg commands using "&", instead use this tool.
46
+ - You must not use echo/cat to read/write files, use ReadFiles/FileWriteOrEdit
47
+ - In order to check status of previous command, use `status_check` with empty command argument.
48
+ - Only command is allowed to run at a time. You need to wait for any previous command to finish before running a new one.
49
+ - Programs don't hang easily, so most likely explanation for no output is usually that the program is still running, and you need to check status again.
50
+ - Do not send Ctrl-c before checking for status till 10 minutes or whatever is appropriate for the program to finish.
51
+ - Only run long running commands in background. Each background command is run in a new non-reusable shell.
52
+ - On running a bg command you'll get a bg command id that you should use to get status or interact.
53
+ """,
54
+ annotations=ToolAnnotations(destructiveHint=True, openWorldHint=True),
55
+ ),
56
+ Tool(
57
+ inputSchema=remove_titles_from_schema(ReadFiles.model_json_schema()),
58
+ name="ReadFiles",
59
+ description="""
60
+ - Read full file content of one or more files.
61
+ - Provide absolute paths only (~ allowed)
62
+ - Only if the task requires line numbers understanding:
63
+ - You may extract a range of lines. E.g., `/path/to/file:1-10` for lines 1-10. You can drop start or end like `/path/to/file:1-` or `/path/to/file:-10`
64
+ """,
65
+ annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False),
66
+ ),
67
+ Tool(
68
+ inputSchema=remove_titles_from_schema(ReadImage.model_json_schema()),
69
+ name="ReadImage",
70
+ description="Read an image from the shell.",
71
+ annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False),
72
+ ),
73
+ Tool(
74
+ inputSchema=remove_titles_from_schema(FileWriteOrEdit.model_json_schema()),
75
+ name="FileWriteOrEdit",
76
+ description="""
77
+ - Writes or edits a file based on the percentage of changes.
78
+ - Use absolute path only (~ allowed).
79
+ - First write down percentage of lines that need to be replaced in the file (between 0-100) in percentage_to_change
80
+ - percentage_to_change should be low if mostly new code is to be added. It should be high if a lot of things are to be replaced.
81
+ - If percentage_to_change > 50, provide full file content in text_or_search_replace_blocks
82
+ - If percentage_to_change <= 50, text_or_search_replace_blocks should be search/replace blocks.
83
+ """
84
+ + diffinstructions,
85
+ annotations=ToolAnnotations(
86
+ destructiveHint=True, idempotentHint=True, openWorldHint=False
87
+ ),
88
+ ),
89
+ Tool(
90
+ inputSchema=remove_titles_from_schema(ContextSave.model_json_schema()),
91
+ name="ContextSave",
92
+ description="""
93
+ Saves provided description and file contents of all the relevant file paths or globs in a single text file.
94
+ - Provide random 3 word unqiue id or whatever user provided.
95
+ - Leave project path as empty string if no project path""",
96
+ annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False),
97
+ ),
98
+ ]