siada-cli 0.0.1__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 (215) hide show
  1. agent_config.yaml +34 -0
  2. siada/__init__.py +13 -0
  3. siada/agent_hub/__init__.py +10 -0
  4. siada/agent_hub/coder/bug_fix_agent.py +398 -0
  5. siada/agent_hub/coder/code_gen_agent.py +235 -0
  6. siada/agent_hub/coder/fe_gen_agent.py +37 -0
  7. siada/agent_hub/coder/issue_review_agent.py +68 -0
  8. siada/agent_hub/coder/prompt/__init__.py +0 -0
  9. siada/agent_hub/coder/prompt/base/__init__.py +0 -0
  10. siada/agent_hub/coder/prompt/base/capabilities.py +18 -0
  11. siada/agent_hub/coder/prompt/base/non_interactive.py +14 -0
  12. siada/agent_hub/coder/prompt/base/prompt_builder.py +33 -0
  13. siada/agent_hub/coder/prompt/base/rules.py +40 -0
  14. siada/agent_hub/coder/prompt/base/tool_use.py +14 -0
  15. siada/agent_hub/coder/prompt/bug_prompt/bug_fix_prompt.py +51 -0
  16. siada/agent_hub/coder/prompt/bug_prompt/rules.py +53 -0
  17. siada/agent_hub/coder/prompt/code_gen_prompt.py +45 -0
  18. siada/agent_hub/coder/prompt/fe_gen_prompt.py +45 -0
  19. siada/agent_hub/coder/prompt/issue_review_prompt.py +121 -0
  20. siada/agent_hub/coder/tracing/__init__.py +22 -0
  21. siada/agent_hub/coder/tracing/logger_tracing_processor.py +458 -0
  22. siada/agent_hub/hooks/__init__.py +0 -0
  23. siada/agent_hub/hooks/io_hooks.py +30 -0
  24. siada/agent_hub/siada_agent.py +214 -0
  25. siada/config/__init__.py +0 -0
  26. siada/config/config_loader.py +67 -0
  27. siada/entrypoint/__init__.py +3 -0
  28. siada/entrypoint/args_parser/__init__.py +0 -0
  29. siada/entrypoint/args_parser/args.py +195 -0
  30. siada/entrypoint/interaction/__init__.py +0 -0
  31. siada/entrypoint/interaction/base_turn.py +107 -0
  32. siada/entrypoint/interaction/config.py +30 -0
  33. siada/entrypoint/interaction/controller.py +122 -0
  34. siada/entrypoint/interaction/nointeractive_controller.py +27 -0
  35. siada/entrypoint/interaction/turn/interface.py +116 -0
  36. siada/entrypoint/interaction/turn/models.py +34 -0
  37. siada/entrypoint/interaction/turn/run_turn.py +641 -0
  38. siada/entrypoint/interaction/turn_factory.py +35 -0
  39. siada/entrypoint/interaction/turn_models.py +28 -0
  40. siada/entrypoint/siadahub.py +413 -0
  41. siada/foundation/__init__.py +0 -0
  42. siada/foundation/code_agent_context.py +48 -0
  43. siada/foundation/config.py +59 -0
  44. siada/foundation/constants.py +2 -0
  45. siada/foundation/context.py +76 -0
  46. siada/foundation/logging.py +341 -0
  47. siada/foundation/tools/__init__.py +0 -0
  48. siada/foundation/tools/get_git_diff.py +105 -0
  49. siada/io/__init__.py +0 -0
  50. siada/io/banner.py +148 -0
  51. siada/io/color_settings.py +207 -0
  52. siada/io/color_utils.py +16 -0
  53. siada/io/components/__init__.py +0 -0
  54. siada/io/components/mdstream.py +204 -0
  55. siada/io/console_printer.py +68 -0
  56. siada/io/io.py +661 -0
  57. siada/io/key_bindings.py +129 -0
  58. siada/io/notification_command.py +40 -0
  59. siada/io/test_banner.py +64 -0
  60. siada/models/__init__.py +0 -0
  61. siada/models/agent.py +47 -0
  62. siada/models/coder/__init__.py +0 -0
  63. siada/models/converter.py +27 -0
  64. siada/models/model_base_config.py +121 -0
  65. siada/models/model_run_config.py +153 -0
  66. siada/provider/__init__.py +0 -0
  67. siada/provider/client_factory.py +112 -0
  68. siada/provider/llm_client.py +11 -0
  69. siada/provider/openrouter/__init__.py +0 -0
  70. siada/provider/openrouter/coverter.py +17 -0
  71. siada/provider/openrouter/openroute_provider.py +33 -0
  72. siada/provider/provider_factory.py +53 -0
  73. siada/queries/tree-sitter-language-pack/README.md +7 -0
  74. siada/queries/tree-sitter-language-pack/arduino-tags.scm +5 -0
  75. siada/queries/tree-sitter-language-pack/c-tags.scm +9 -0
  76. siada/queries/tree-sitter-language-pack/chatito-tags.scm +16 -0
  77. siada/queries/tree-sitter-language-pack/commonlisp-tags.scm +122 -0
  78. siada/queries/tree-sitter-language-pack/cpp-tags.scm +15 -0
  79. siada/queries/tree-sitter-language-pack/csharp-tags.scm +26 -0
  80. siada/queries/tree-sitter-language-pack/d-tags.scm +26 -0
  81. siada/queries/tree-sitter-language-pack/dart-tags.scm +92 -0
  82. siada/queries/tree-sitter-language-pack/elisp-tags.scm +5 -0
  83. siada/queries/tree-sitter-language-pack/elixir-tags.scm +54 -0
  84. siada/queries/tree-sitter-language-pack/elm-tags.scm +19 -0
  85. siada/queries/tree-sitter-language-pack/gleam-tags.scm +41 -0
  86. siada/queries/tree-sitter-language-pack/go-tags.scm +42 -0
  87. siada/queries/tree-sitter-language-pack/java-tags.scm +20 -0
  88. siada/queries/tree-sitter-language-pack/javascript-tags.scm +88 -0
  89. siada/queries/tree-sitter-language-pack/lua-tags.scm +34 -0
  90. siada/queries/tree-sitter-language-pack/ocaml-tags.scm +115 -0
  91. siada/queries/tree-sitter-language-pack/ocaml_interface-tags.scm +98 -0
  92. siada/queries/tree-sitter-language-pack/pony-tags.scm +39 -0
  93. siada/queries/tree-sitter-language-pack/properties-tags.scm +5 -0
  94. siada/queries/tree-sitter-language-pack/python-tags.scm +14 -0
  95. siada/queries/tree-sitter-language-pack/r-tags.scm +21 -0
  96. siada/queries/tree-sitter-language-pack/racket-tags.scm +12 -0
  97. siada/queries/tree-sitter-language-pack/ruby-tags.scm +64 -0
  98. siada/queries/tree-sitter-language-pack/rust-tags.scm +60 -0
  99. siada/queries/tree-sitter-language-pack/solidity-tags.scm +43 -0
  100. siada/queries/tree-sitter-language-pack/swift-tags.scm +51 -0
  101. siada/queries/tree-sitter-language-pack/udev-tags.scm +20 -0
  102. siada/queries/tree-sitter-languages/README.md +23 -0
  103. siada/queries/tree-sitter-languages/c-tags.scm +9 -0
  104. siada/queries/tree-sitter-languages/c_sharp-tags.scm +46 -0
  105. siada/queries/tree-sitter-languages/cpp-tags.scm +15 -0
  106. siada/queries/tree-sitter-languages/dart-tags.scm +91 -0
  107. siada/queries/tree-sitter-languages/elisp-tags.scm +8 -0
  108. siada/queries/tree-sitter-languages/elixir-tags.scm +54 -0
  109. siada/queries/tree-sitter-languages/elm-tags.scm +19 -0
  110. siada/queries/tree-sitter-languages/go-tags.scm +30 -0
  111. siada/queries/tree-sitter-languages/hcl-tags.scm +77 -0
  112. siada/queries/tree-sitter-languages/java-tags.scm +20 -0
  113. siada/queries/tree-sitter-languages/javascript-tags.scm +88 -0
  114. siada/queries/tree-sitter-languages/kotlin-tags.scm +27 -0
  115. siada/queries/tree-sitter-languages/ocaml-tags.scm +115 -0
  116. siada/queries/tree-sitter-languages/ocaml_interface-tags.scm +98 -0
  117. siada/queries/tree-sitter-languages/php-tags.scm +26 -0
  118. siada/queries/tree-sitter-languages/python-tags.scm +12 -0
  119. siada/queries/tree-sitter-languages/ql-tags.scm +26 -0
  120. siada/queries/tree-sitter-languages/ruby-tags.scm +64 -0
  121. siada/queries/tree-sitter-languages/rust-tags.scm +60 -0
  122. siada/queries/tree-sitter-languages/scala-tags.scm +65 -0
  123. siada/queries/tree-sitter-languages/typescript-tags.scm +41 -0
  124. siada/services/__init__.py +30 -0
  125. siada/services/at_file_recommendation_design_document.md +1213 -0
  126. siada/services/code_context_manager.py +136 -0
  127. siada/services/enhanced_fix_result_check.py +546 -0
  128. siada/services/execution_trace_collector.py +207 -0
  129. siada/services/file_recommendation/__init__.py +136 -0
  130. siada/services/file_recommendation/core/__init__.py +17 -0
  131. siada/services/file_recommendation/core/completion_engine.py +351 -0
  132. siada/services/file_recommendation/core/config.py +62 -0
  133. siada/services/file_recommendation/core/file_discovery.py +353 -0
  134. siada/services/file_recommendation/core/suggestion.py +110 -0
  135. siada/services/file_recommendation/utils/__init__.py +22 -0
  136. siada/services/file_recommendation/utils/path_utils.py +138 -0
  137. siada/services/file_recommendation/utils/text_utils.py +253 -0
  138. siada/services/fix_result_check.py +377 -0
  139. siada/services/handle_at_command/README.md +247 -0
  140. siada/services/handle_at_command/__init__.py +115 -0
  141. siada/services/handle_at_command/exceptions.py +46 -0
  142. siada/services/handle_at_command/models.py +92 -0
  143. siada/services/handle_at_command/parser.py +189 -0
  144. siada/services/handle_at_command/processor.py +321 -0
  145. siada/services/handle_at_command/resolver.py +318 -0
  146. siada/services/handle_at_command/utils.py +345 -0
  147. siada/services/model_info_service.py +103 -0
  148. siada/services/siada_runner.py +170 -0
  149. siada/session/__init__.py +29 -0
  150. siada/session/session_manager.py +66 -0
  151. siada/session/session_models.py +43 -0
  152. siada/support/__init__.py +0 -0
  153. siada/support/completer.py +204 -0
  154. siada/support/editor.py +146 -0
  155. siada/support/envprocessor.py +54 -0
  156. siada/support/repo.py +51 -0
  157. siada/support/slash_commands.py +410 -0
  158. siada/support/spinner.py +320 -0
  159. siada/tools/__init__.py +0 -0
  160. siada/tools/ast/README.md +345 -0
  161. siada/tools/ast/ast_tool.py +341 -0
  162. siada/tools/ast/models.py +11 -0
  163. siada/tools/coder/__init__.py +0 -0
  164. siada/tools/coder/ask_followup_question.py +70 -0
  165. siada/tools/coder/cmd_runner.py +132 -0
  166. siada/tools/coder/file_operator.py +284 -0
  167. siada/tools/coder/file_search/README.md +223 -0
  168. siada/tools/coder/file_search/__init__.py +10 -0
  169. siada/tools/coder/file_search/bin/rg-linux-arm64 +0 -0
  170. siada/tools/coder/file_search/bin/rg-linux-x64 +0 -0
  171. siada/tools/coder/file_search/bin/rg-macos-arm64 +0 -0
  172. siada/tools/coder/file_search/bin/rg-macos-x64 +0 -0
  173. siada/tools/coder/file_search/bin/rg.exe +0 -0
  174. siada/tools/coder/file_search/search.py +437 -0
  175. siada/tools/coder/file_search/setup_package.py +150 -0
  176. siada/tools/coder/file_search/test_search.py +142 -0
  177. siada/tools/coder/files.py +147 -0
  178. siada/tools/coder/fix_attempt_completion.py +58 -0
  179. siada/tools/coder/issue_review_completion.py +29 -0
  180. siada/tools/coder/observation/__init__.py +0 -0
  181. siada/tools/coder/observation/error.py +22 -0
  182. siada/tools/coder/observation/file_observation.py +216 -0
  183. siada/tools/coder/observation/observation.py +71 -0
  184. siada/tools/coder/repo_map/__init__.py +157 -0
  185. siada/tools/coder/repo_map/dump.py +29 -0
  186. siada/tools/coder/repo_map/io.py +226 -0
  187. siada/tools/coder/repo_map/repo_map.py +881 -0
  188. siada/tools/coder/repo_map/special.py +203 -0
  189. siada/tools/coder/repo_map/token_counter.py +154 -0
  190. siada/tools/coder/repo_map/waiting.py +221 -0
  191. siada/tools/coder/reproduce_completion.py +39 -0
  192. siada/tools/coder/run_cmd.py +60 -0
  193. siada/tools/coder/test_completion.py +25 -0
  194. siada/tools/coder/tool_docs.py +44 -0
  195. siada/tools/compression_tool.py +200 -0
  196. siada/tools/read_many_files/README.md +220 -0
  197. siada/tools/read_many_files/__init__.py +25 -0
  198. siada/tools/read_many_files/file_processor.py +486 -0
  199. siada/tools/read_many_files/filters.py +326 -0
  200. siada/tools/read_many_files/formatters.py +204 -0
  201. siada/tools/read_many_files/models.py +189 -0
  202. siada/tools/read_many_files_tool.py +233 -0
  203. siada/tools/tool_call_format/__init__.py +55 -0
  204. siada/tools/tool_call_format/file_to_language.py +231 -0
  205. siada/tools/tool_call_format/formatter_factory.py +80 -0
  206. siada/tools/tool_call_format/formatters.py +282 -0
  207. siada/tools/tool_call_format/tool_call_formatter.py +54 -0
  208. siada/tools/web/__init__.py +0 -0
  209. siada/tools/web/web_crawl.py +208 -0
  210. siada/utils.py +194 -0
  211. siada_cli-0.0.1.dist-info/LICENSE +201 -0
  212. siada_cli-0.0.1.dist-info/METADATA +457 -0
  213. siada_cli-0.0.1.dist-info/RECORD +215 -0
  214. siada_cli-0.0.1.dist-info/WHEEL +4 -0
  215. siada_cli-0.0.1.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,321 @@
1
+ """
2
+ AtCommand Processor - Main processor that coordinates all components.
3
+ """
4
+
5
+ import time
6
+ import re
7
+ from typing import List, Dict, Optional, Any, Tuple
8
+
9
+ from .models import (
10
+ HandleAtCommandParams,
11
+ HandleAtCommandResult,
12
+ AtCommandPart,
13
+ ResolverContext,
14
+ ProcessingStats,
15
+ IgnoredFileStats
16
+ )
17
+ from .parser import AtCommandParser
18
+ from .resolver import PathResolver
19
+ from .exceptions import AtCommandError
20
+
21
+ # Import ReadManyFiles tool
22
+ from siada.tools.read_many_files_tool import ReadManyFilesTool
23
+ from siada.tools.read_many_files.models import ReadManyFilesParams
24
+
25
+
26
+ class AtCommandProcessor:
27
+ """Main processor for handling @ commands"""
28
+
29
+ def __init__(self, config=None):
30
+ self.config = config
31
+ self.parser = AtCommandParser()
32
+ self.stats = ProcessingStats()
33
+ self.ignored_stats = IgnoredFileStats()
34
+
35
+ # Regex to parse file content from read_many_files output
36
+ self.file_content_regex = re.compile(r'^--- (.*?) ---\n\n([\s\S]*?)\n\n$')
37
+
38
+ async def handle_at_command(self, params: HandleAtCommandParams) -> HandleAtCommandResult:
39
+ """
40
+ Main entry point for handling @ commands
41
+
42
+ Args:
43
+ params: HandleAtCommandParams with all necessary parameters
44
+
45
+ Returns:
46
+ HandleAtCommandResult with processed query and status
47
+ """
48
+ start_time = time.time()
49
+
50
+ try:
51
+ # 1. Parse user input
52
+ command_parts = self.parser.parse_all_at_commands(params.query)
53
+ at_path_parts = [part for part in command_parts if part.type == 'atPath']
54
+
55
+ self.stats.total_at_commands = len(at_path_parts)
56
+
57
+ # 2. Early exit if no @ commands
58
+ if not at_path_parts:
59
+ params.add_item({'type': 'user', 'text': params.query}, params.message_id)
60
+ return HandleAtCommandResult([{'text': params.query}], True)
61
+
62
+ params.add_item({'type': 'user', 'text': params.query}, params.message_id)
63
+
64
+ # 3. Initialize resolver context
65
+ resolver_context = self._create_resolver_context(params.config)
66
+ resolver = PathResolver(resolver_context)
67
+
68
+ # 4. Resolve paths
69
+ paths_to_read = []
70
+ at_path_to_resolved_map = {}
71
+ content_labels = []
72
+
73
+ for at_path_part in at_path_parts:
74
+ try:
75
+ resolution_result = await resolver.resolve_path(
76
+ at_path_part.content,
77
+ params.on_debug_message
78
+ )
79
+
80
+ if resolution_result.resolved_path:
81
+ paths_to_read.append(resolution_result.resolved_path)
82
+ at_path_to_resolved_map[at_path_part.content] = resolution_result.resolved_path
83
+ content_labels.append(at_path_part.content[1:]) # Remove @
84
+ self.stats.resolved_paths += 1
85
+ else:
86
+ params.on_debug_message(f'Failed to resolve {at_path_part.content}: {resolution_result.reason}')
87
+ self.stats.failed_paths += 1
88
+
89
+ except AtCommandError as e:
90
+ params.on_debug_message(f'Error resolving {at_path_part.content}: {e}')
91
+ self.stats.failed_paths += 1
92
+ continue
93
+
94
+ # 5. Handle case with no valid paths
95
+ if not paths_to_read:
96
+ params.on_debug_message('No valid file paths found in @ commands to read.')
97
+ initial_query = self._rebuild_initial_query(command_parts, at_path_to_resolved_map)
98
+ return HandleAtCommandResult([{'text': initial_query or params.query}], True)
99
+
100
+ # 6. Read files using ReadManyFilesTool
101
+ try:
102
+ file_contents = await self._read_files(
103
+ paths_to_read,
104
+ resolver_context.target_directory,
105
+ resolver_context.file_filtering_options,
106
+ params.signal
107
+ )
108
+
109
+ self.stats.files_read = len(file_contents) if file_contents else 0
110
+
111
+ # 7. Build processed query
112
+ processed_query = self._build_processed_query(
113
+ command_parts,
114
+ at_path_to_resolved_map,
115
+ file_contents
116
+ )
117
+
118
+ # 8. Record success
119
+ self._record_tool_success(content_labels, params.add_item, params.message_id)
120
+
121
+ # 9. Update processing time
122
+ self.stats.processing_time = time.time() - start_time
123
+
124
+ return HandleAtCommandResult(processed_query, True)
125
+
126
+ except Exception as error:
127
+ # 10. Handle file reading errors
128
+ self._handle_read_error(error, content_labels, params.add_item, params.message_id)
129
+ return HandleAtCommandResult(None, False)
130
+
131
+ except Exception as error:
132
+ # Handle unexpected errors
133
+ params.on_debug_message(f'Unexpected error in handleAtCommand: {error}')
134
+ params.add_item(
135
+ {'type': 'error', 'text': f'Unexpected error processing @ commands: {str(error)}'},
136
+ params.message_id
137
+ )
138
+ return HandleAtCommandResult(None, False)
139
+
140
+ def _create_resolver_context(self, config) -> ResolverContext:
141
+ """
142
+ Create resolver context from config
143
+
144
+ Args:
145
+ config: Configuration object
146
+
147
+ Returns:
148
+ ResolverContext for path resolution
149
+ """
150
+ # Extract configuration values
151
+ # This is a simplified implementation - in practice, you'd extract from actual config
152
+ workspace_directories = [config.root_dir] if hasattr(config, 'root_dir') and config.root_dir else ['.']
153
+ target_directory = config.root_dir if hasattr(config, 'root_dir') and config.root_dir else '.'
154
+
155
+ return ResolverContext(
156
+ workspace_directories=workspace_directories,
157
+ target_directory=target_directory,
158
+ enable_recursive_search=True,
159
+ file_filtering_options={
160
+ 'respect_git_ignore': True
161
+ }
162
+ )
163
+
164
+ async def _read_files(self, paths: List[str], target_dir: str,
165
+ filtering_options: Dict[str, bool], signal=None) -> List[Any]:
166
+ """
167
+ Read files using ReadManyFilesTool
168
+
169
+ Args:
170
+ paths: List of file paths to read
171
+ target_dir: Target directory
172
+ filtering_options: File filtering options
173
+ signal: Cancellation signal
174
+
175
+ Returns:
176
+ List of file contents
177
+ """
178
+ tool = ReadManyFilesTool(target_dir)
179
+
180
+ params = ReadManyFilesParams(
181
+ paths=paths,
182
+ file_filtering_options=filtering_options
183
+ )
184
+
185
+ result = await tool.execute(params, signal)
186
+
187
+ if result and hasattr(result, 'llmContent'):
188
+ return result.llmContent
189
+
190
+ return []
191
+
192
+ def _rebuild_initial_query(self, command_parts: List[AtCommandPart],
193
+ at_path_to_resolved_map: Dict[str, str]) -> str:
194
+ """
195
+ Rebuild the initial query text with resolved paths
196
+
197
+ Args:
198
+ command_parts: Parsed command parts
199
+ at_path_to_resolved_map: Mapping of @ paths to resolved paths
200
+
201
+ Returns:
202
+ Rebuilt query string
203
+ """
204
+ initial_query_text = ""
205
+
206
+ for i, part in enumerate(command_parts):
207
+ if part.type == 'text':
208
+ initial_query_text += part.content
209
+ else: # atPath
210
+ resolved_spec = at_path_to_resolved_map.get(part.content)
211
+
212
+ # Add appropriate spacing
213
+ if (i > 0 and initial_query_text and
214
+ not initial_query_text.endswith(' ') and
215
+ (resolved_spec or not part.content.startswith(' '))):
216
+ initial_query_text += ' '
217
+
218
+ if resolved_spec:
219
+ initial_query_text += f'@{resolved_spec}'
220
+ else:
221
+ initial_query_text += part.content
222
+
223
+ return initial_query_text.strip()
224
+
225
+ def _build_processed_query(self, command_parts: List[AtCommandPart],
226
+ at_path_to_resolved_map: Dict[str, str],
227
+ file_contents: List[Any]) -> List[Dict]:
228
+ """
229
+ Build the processed query with file contents injected
230
+
231
+ Args:
232
+ command_parts: Parsed command parts
233
+ at_path_to_resolved_map: Mapping of @ paths to resolved paths
234
+ file_contents: File contents from ReadManyFilesTool
235
+
236
+ Returns:
237
+ List of processed query parts
238
+ """
239
+ # 1. Rebuild initial query text
240
+ initial_query_text = self._rebuild_initial_query(command_parts, at_path_to_resolved_map)
241
+
242
+ # 2. Start with the initial query
243
+ processed_parts = [{'text': initial_query_text}]
244
+
245
+ # 3. Add file contents if any
246
+ if file_contents:
247
+ processed_parts.append({'text': '\n--- Content from referenced files ---'})
248
+
249
+ for file_content_part in file_contents:
250
+ if isinstance(file_content_part, str):
251
+ # Parse file content format: "--- filepath ---\n\ncontent\n\n"
252
+ file_path, content = self.parser.extract_file_content_info(file_content_part)
253
+
254
+ if file_path:
255
+ processed_parts.append({'text': f'\nContent from @{file_path}:\n'})
256
+ processed_parts.append({'text': content})
257
+ else:
258
+ processed_parts.append({'text': file_content_part})
259
+ else:
260
+ # Non-string content (e.g., image Part objects)
261
+ processed_parts.append(file_content_part)
262
+
263
+ processed_parts.append({'text': '\n--- End of content ---'})
264
+
265
+ return processed_parts
266
+
267
+ def _record_tool_success(self, content_labels: List[str], add_item: callable, message_id: int):
268
+ """
269
+ Record successful tool execution
270
+
271
+ Args:
272
+ content_labels: List of content labels
273
+ add_item: Function to add items to history
274
+ message_id: Message ID
275
+ """
276
+ if content_labels:
277
+ success_message = f"Successfully read {len(content_labels)} file(s): {', '.join(content_labels)}"
278
+ add_item(
279
+ {'type': 'tool_success', 'text': success_message},
280
+ message_id
281
+ )
282
+
283
+ def _handle_read_error(self, error: Exception, content_labels: List[str],
284
+ add_item: callable, message_id: int):
285
+ """
286
+ Handle file reading errors
287
+
288
+ Args:
289
+ error: Exception that occurred
290
+ content_labels: List of content labels that were being processed
291
+ add_item: Function to add items to history
292
+ message_id: Message ID
293
+ """
294
+ error_message = f"Error reading files {', '.join(content_labels)}: {str(error)}"
295
+ add_item(
296
+ {'type': 'error', 'text': error_message},
297
+ message_id
298
+ )
299
+
300
+ def get_processing_stats(self) -> ProcessingStats:
301
+ """
302
+ Get processing statistics
303
+
304
+ Returns:
305
+ ProcessingStats object
306
+ """
307
+ return self.stats
308
+
309
+ def get_ignored_stats(self) -> IgnoredFileStats:
310
+ """
311
+ Get ignored file statistics
312
+
313
+ Returns:
314
+ IgnoredFileStats object
315
+ """
316
+ return self.ignored_stats
317
+
318
+ def reset_stats(self):
319
+ """Reset processing statistics"""
320
+ self.stats = ProcessingStats()
321
+ self.ignored_stats = IgnoredFileStats()
@@ -0,0 +1,318 @@
1
+ """
2
+ Path Resolver - Resolves @ command paths to actual file paths.
3
+ """
4
+
5
+ import os
6
+ import glob
7
+ from pathlib import Path
8
+ from typing import List, Optional, Tuple
9
+
10
+ from .models import ResolverContext, PathResolutionResult
11
+ from .exceptions import (
12
+ PathNotFoundError,
13
+ WorkspaceSecurityError,
14
+ PathIgnoredError,
15
+ InvalidPathError
16
+ )
17
+
18
+
19
+ class PathResolver:
20
+ """Resolves @ command paths to actual file paths"""
21
+
22
+ def __init__(self, context: ResolverContext):
23
+ self.context = context
24
+ self.target_dir = Path(context.target_directory).resolve()
25
+
26
+ async def resolve_path(self, at_path: str, on_debug_message: callable = None) -> PathResolutionResult:
27
+ """
28
+ Resolve an @ path to an actual file path
29
+
30
+ Args:
31
+ at_path: @ path string (e.g., "@file.py", "@directory/")
32
+ on_debug_message: Optional debug message callback
33
+
34
+ Returns:
35
+ PathResolutionResult with resolution details
36
+ """
37
+ if not on_debug_message:
38
+ on_debug_message = lambda msg: None
39
+
40
+ # Handle lone '@' symbol
41
+ if at_path == '@':
42
+ on_debug_message('Lone @ detected, treating as text')
43
+ return PathResolutionResult(
44
+ resolved_path=None,
45
+ original_path=at_path,
46
+ resolution_type='not_found',
47
+ reason='Lone @ symbol treated as text'
48
+ )
49
+
50
+ # Extract path without '@' prefix
51
+ if not at_path.startswith('@'):
52
+ raise InvalidPathError(f"Invalid @ path format: {at_path}")
53
+
54
+ path_name = at_path[1:] # Remove '@' prefix
55
+
56
+ if not path_name:
57
+ return PathResolutionResult(
58
+ resolved_path=None,
59
+ original_path=at_path,
60
+ resolution_type='not_found',
61
+ reason='Empty path after @'
62
+ )
63
+
64
+ # Security validation
65
+ if not self._is_path_safe(path_name):
66
+ raise WorkspaceSecurityError(f"Path contains unsafe elements: {path_name}")
67
+
68
+ # Try to resolve path in each workspace directory
69
+ for workspace_dir in self.context.workspace_directories:
70
+ result = await self._try_resolve_in_workspace(path_name, workspace_dir, on_debug_message)
71
+ if result.resolved_path:
72
+ return result
73
+
74
+ # If not found, return not_found result
75
+ on_debug_message(f'Path {path_name} not found in any workspace directory')
76
+ return PathResolutionResult(
77
+ resolved_path=None,
78
+ original_path=at_path,
79
+ resolution_type='not_found',
80
+ reason=f'Path not found in workspace directories: {self.context.workspace_directories}'
81
+ )
82
+
83
+ async def _try_resolve_in_workspace(self, path_name: str, workspace_dir: str,
84
+ on_debug_message: callable) -> PathResolutionResult:
85
+ """
86
+ Try to resolve path within a specific workspace directory
87
+
88
+ Args:
89
+ path_name: Path name without '@' prefix
90
+ workspace_dir: Workspace directory to search in
91
+ on_debug_message: Debug message callback
92
+
93
+ Returns:
94
+ PathResolutionResult
95
+ """
96
+ workspace_path = Path(workspace_dir).resolve()
97
+
98
+ # Check if workspace directory is within target directory
99
+ if not self._is_workspace_within_target(workspace_path):
100
+ on_debug_message(f'Workspace {workspace_dir} is not within target directory {self.target_dir}')
101
+ return PathResolutionResult(
102
+ resolved_path=None,
103
+ original_path=f"@{path_name}",
104
+ resolution_type='not_found',
105
+ reason='Workspace not within target directory'
106
+ )
107
+
108
+ # Try direct path resolution
109
+ direct_result = self._try_direct_resolution(path_name, workspace_path, on_debug_message)
110
+ if direct_result.resolved_path:
111
+ return direct_result
112
+
113
+ # Try glob search if enabled
114
+ if self.context.enable_recursive_search:
115
+ glob_result = await self._try_glob_search(path_name, workspace_path, on_debug_message)
116
+ if glob_result.resolved_path:
117
+ return glob_result
118
+
119
+ return PathResolutionResult(
120
+ resolved_path=None,
121
+ original_path=f"@{path_name}",
122
+ resolution_type='not_found',
123
+ reason=f'Not found in workspace: {workspace_dir}'
124
+ )
125
+
126
+ def _try_direct_resolution(self, path_name: str, workspace_path: Path,
127
+ on_debug_message: callable) -> PathResolutionResult:
128
+ """
129
+ Try direct path resolution (exact match)
130
+
131
+ Args:
132
+ path_name: Path name to resolve
133
+ workspace_path: Workspace directory path
134
+ on_debug_message: Debug message callback
135
+
136
+ Returns:
137
+ PathResolutionResult
138
+ """
139
+ try:
140
+ absolute_path = workspace_path / path_name
141
+ resolved_path = absolute_path.resolve()
142
+
143
+ # Security check - ensure resolved path is within workspace
144
+ if not self._is_path_within_workspace(resolved_path, workspace_path):
145
+ raise WorkspaceSecurityError(f"Resolved path is outside workspace: {resolved_path}")
146
+
147
+ if resolved_path.exists():
148
+ if resolved_path.is_dir():
149
+ # Directory - convert to glob pattern
150
+ glob_pattern = path_name + ('**' if path_name.endswith('/') else '/**')
151
+ on_debug_message(f'Path {path_name} resolved to directory, using glob: {glob_pattern}')
152
+ return PathResolutionResult(
153
+ resolved_path=glob_pattern,
154
+ original_path=f"@{path_name}",
155
+ resolution_type='directory',
156
+ reason=f'Directory converted to glob pattern: {glob_pattern}'
157
+ )
158
+ else:
159
+ # File - return relative path
160
+ relative_path = os.path.relpath(str(resolved_path), str(workspace_path))
161
+ on_debug_message(f'Path {path_name} resolved to file: {resolved_path}')
162
+ return PathResolutionResult(
163
+ resolved_path=relative_path,
164
+ original_path=f"@{path_name}",
165
+ resolution_type='direct',
166
+ reason=f'Direct file match: {resolved_path}'
167
+ )
168
+
169
+ except (OSError, ValueError) as e:
170
+ on_debug_message(f'Error resolving path {path_name}: {e}')
171
+
172
+ return PathResolutionResult(
173
+ resolved_path=None,
174
+ original_path=f"@{path_name}",
175
+ resolution_type='not_found',
176
+ reason='Direct resolution failed'
177
+ )
178
+
179
+ async def _try_glob_search(self, path_name: str, workspace_path: Path,
180
+ on_debug_message: callable) -> PathResolutionResult:
181
+ """
182
+ Try glob search for fuzzy matching
183
+
184
+ Args:
185
+ path_name: Path name to search for
186
+ workspace_path: Workspace directory path
187
+ on_debug_message: Debug message callback
188
+
189
+ Returns:
190
+ PathResolutionResult
191
+ """
192
+ on_debug_message(f'Path {path_name} not found directly, attempting glob search')
193
+
194
+ try:
195
+ # Create glob pattern for fuzzy search
196
+ glob_pattern = f"**/*{path_name}*"
197
+ search_path = workspace_path / glob_pattern
198
+
199
+ # Use glob to find matching files
200
+ matches = list(workspace_path.glob(glob_pattern))
201
+
202
+ if matches:
203
+ # Take the first match
204
+ first_match = matches[0].resolve()
205
+
206
+ # Security check
207
+ if not self._is_path_within_workspace(first_match, workspace_path):
208
+ raise WorkspaceSecurityError(f"Glob match is outside workspace: {first_match}")
209
+
210
+ relative_path = os.path.relpath(str(first_match), str(workspace_path))
211
+ on_debug_message(f'Glob search for {path_name} found {first_match}, using relative path: {relative_path}')
212
+
213
+ return PathResolutionResult(
214
+ resolved_path=relative_path,
215
+ original_path=f"@{path_name}",
216
+ resolution_type='glob',
217
+ reason=f'Glob search found: {first_match} (first of {len(matches)} matches)'
218
+ )
219
+ else:
220
+ on_debug_message(f'Glob search for "**/*{path_name}*" found no files')
221
+
222
+ except Exception as e:
223
+ on_debug_message(f'Error during glob search for {path_name}: {e}')
224
+
225
+ return PathResolutionResult(
226
+ resolved_path=None,
227
+ original_path=f"@{path_name}",
228
+ resolution_type='not_found',
229
+ reason='Glob search found no matches'
230
+ )
231
+
232
+ def _is_path_safe(self, path_name: str) -> bool:
233
+ """
234
+ Check if path is safe (no path traversal attempts)
235
+
236
+ Args:
237
+ path_name: Path to check
238
+
239
+ Returns:
240
+ True if path is safe, False otherwise
241
+ """
242
+ # Check for path traversal patterns
243
+ dangerous_patterns = ['../', '..\\', '../', '..\\']
244
+ for pattern in dangerous_patterns:
245
+ if pattern in path_name:
246
+ return False
247
+
248
+ # Check for absolute paths
249
+ if os.path.isabs(path_name):
250
+ return False
251
+
252
+ # Check for null bytes
253
+ if '\x00' in path_name:
254
+ return False
255
+
256
+ return True
257
+
258
+ def _is_workspace_within_target(self, workspace_path: Path) -> bool:
259
+ """
260
+ Check if workspace directory is within target directory
261
+
262
+ Args:
263
+ workspace_path: Workspace directory path
264
+
265
+ Returns:
266
+ True if workspace is within target, False otherwise
267
+ """
268
+ try:
269
+ workspace_path.relative_to(self.target_dir)
270
+ return True
271
+ except ValueError:
272
+ return False
273
+
274
+ def _is_path_within_workspace(self, file_path: Path, workspace_path: Path) -> bool:
275
+ """
276
+ Check if file path is within workspace directory
277
+
278
+ Args:
279
+ file_path: File path to check
280
+ workspace_path: Workspace directory path
281
+
282
+ Returns:
283
+ True if file is within workspace, False otherwise
284
+ """
285
+ try:
286
+ file_path.relative_to(workspace_path)
287
+ return True
288
+ except ValueError:
289
+ return False
290
+
291
+ def should_ignore_file(self, file_path: str) -> Tuple[bool, Optional[str]]:
292
+ """
293
+ Check if file should be ignored based on filtering options
294
+
295
+ Args:
296
+ file_path: File path to check
297
+
298
+ Returns:
299
+ Tuple of (should_ignore, reason)
300
+ """
301
+ # This is a simplified implementation
302
+ # In a full implementation, this would integrate with .gitignore parsing
303
+ # For now, we'll use basic patterns
304
+
305
+ ignore_patterns = [
306
+ '__pycache__',
307
+ '.git',
308
+ '.vscode',
309
+ '.idea',
310
+ 'node_modules',
311
+ '.env'
312
+ ]
313
+
314
+ for pattern in ignore_patterns:
315
+ if pattern in file_path:
316
+ return True, f"Matches ignore pattern: {pattern}"
317
+
318
+ return False, None