tarang 4.4.0__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.
- tarang/__init__.py +23 -0
- tarang/cli.py +1168 -0
- tarang/client/__init__.py +19 -0
- tarang/client/api_client.py +701 -0
- tarang/client/auth.py +178 -0
- tarang/context/__init__.py +41 -0
- tarang/context/bm25.py +218 -0
- tarang/context/chunker.py +984 -0
- tarang/context/graph.py +464 -0
- tarang/context/indexer.py +514 -0
- tarang/context/retriever.py +270 -0
- tarang/context/skeleton.py +282 -0
- tarang/context_collector.py +449 -0
- tarang/executor/__init__.py +6 -0
- tarang/executor/diff_apply.py +246 -0
- tarang/executor/linter.py +184 -0
- tarang/stream.py +1346 -0
- tarang/ui/__init__.py +7 -0
- tarang/ui/console.py +407 -0
- tarang/ui/diff_viewer.py +146 -0
- tarang/ui/formatter.py +1151 -0
- tarang/ui/keyboard.py +197 -0
- tarang/ws/__init__.py +14 -0
- tarang/ws/client.py +464 -0
- tarang/ws/executor.py +638 -0
- tarang/ws/handlers.py +590 -0
- tarang-4.4.0.dist-info/METADATA +102 -0
- tarang-4.4.0.dist-info/RECORD +31 -0
- tarang-4.4.0.dist-info/WHEEL +5 -0
- tarang-4.4.0.dist-info/entry_points.txt +2 -0
- tarang-4.4.0.dist-info/top_level.txt +1 -0
tarang/ws/executor.py
ADDED
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tool Executor for Local Tool Execution.
|
|
3
|
+
|
|
4
|
+
Executes tools requested by the backend agent:
|
|
5
|
+
- File operations (read, write, edit, delete)
|
|
6
|
+
- Directory operations (list, create)
|
|
7
|
+
- Shell commands
|
|
8
|
+
- Search operations
|
|
9
|
+
|
|
10
|
+
All operations are executed locally on the user's machine.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import fnmatch
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import subprocess
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Type for approval callback
|
|
26
|
+
ApprovalCallback = Callable[[str, str, Dict[str, Any]], bool]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ToolExecutor:
|
|
30
|
+
"""
|
|
31
|
+
Executes tools locally for the hybrid architecture.
|
|
32
|
+
|
|
33
|
+
Usage:
|
|
34
|
+
executor = ToolExecutor(project_root="/path/to/project")
|
|
35
|
+
result = await executor.execute("read_file", {"file_path": "src/main.py"})
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
# Maximum file size to read (10MB)
|
|
39
|
+
MAX_FILE_SIZE = 10 * 1024 * 1024
|
|
40
|
+
|
|
41
|
+
# Maximum lines to return for file reads
|
|
42
|
+
MAX_LINES = 2000
|
|
43
|
+
|
|
44
|
+
# Shell command timeout (seconds)
|
|
45
|
+
SHELL_TIMEOUT = 60
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
project_root: str,
|
|
50
|
+
approval_callback: Optional[ApprovalCallback] = None,
|
|
51
|
+
):
|
|
52
|
+
self.project_root = Path(project_root).resolve()
|
|
53
|
+
self.approval_callback = approval_callback
|
|
54
|
+
|
|
55
|
+
# Tool registry
|
|
56
|
+
self._tools: Dict[str, Callable] = {
|
|
57
|
+
"read_file": self._read_file,
|
|
58
|
+
"write_file": self._write_file,
|
|
59
|
+
"edit_file": self._edit_file,
|
|
60
|
+
"delete_file": self._delete_file,
|
|
61
|
+
"list_files": self._list_files,
|
|
62
|
+
"search_files": self._search_files,
|
|
63
|
+
"search_code": self._search_code,
|
|
64
|
+
"get_file_info": self._get_file_info,
|
|
65
|
+
"create_directory": self._create_directory,
|
|
66
|
+
"shell": self._shell,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async def execute(
|
|
70
|
+
self,
|
|
71
|
+
tool: str,
|
|
72
|
+
args: Dict[str, Any],
|
|
73
|
+
require_approval: bool = False,
|
|
74
|
+
) -> Dict[str, Any]:
|
|
75
|
+
"""
|
|
76
|
+
Execute a tool with the given arguments.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
tool: Tool name
|
|
80
|
+
args: Tool arguments
|
|
81
|
+
require_approval: Whether to ask for user approval
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Tool result dictionary
|
|
85
|
+
"""
|
|
86
|
+
if tool not in self._tools:
|
|
87
|
+
return {"error": f"Unknown tool: {tool}"}
|
|
88
|
+
|
|
89
|
+
# Check approval if required
|
|
90
|
+
if require_approval and self.approval_callback:
|
|
91
|
+
description = self._get_tool_description(tool, args)
|
|
92
|
+
approved = self.approval_callback(tool, description, args)
|
|
93
|
+
if not approved:
|
|
94
|
+
return {"skipped": True, "message": "User rejected operation"}
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
handler = self._tools[tool]
|
|
98
|
+
result = await handler(**args)
|
|
99
|
+
return result
|
|
100
|
+
except TypeError as e:
|
|
101
|
+
return {"error": f"Invalid arguments for {tool}: {e}"}
|
|
102
|
+
except Exception as e:
|
|
103
|
+
logger.exception(f"Tool execution error: {tool}")
|
|
104
|
+
return {"error": str(e)}
|
|
105
|
+
|
|
106
|
+
def _resolve_path(self, file_path: str) -> Path:
|
|
107
|
+
"""Resolve a path relative to project root."""
|
|
108
|
+
path = Path(file_path)
|
|
109
|
+
|
|
110
|
+
# If absolute and within project, use as-is
|
|
111
|
+
if path.is_absolute():
|
|
112
|
+
try:
|
|
113
|
+
path.relative_to(self.project_root)
|
|
114
|
+
return path
|
|
115
|
+
except ValueError:
|
|
116
|
+
# Outside project - treat as relative
|
|
117
|
+
path = Path(file_path.lstrip("/"))
|
|
118
|
+
|
|
119
|
+
# Resolve relative to project root
|
|
120
|
+
resolved = (self.project_root / path).resolve()
|
|
121
|
+
|
|
122
|
+
# Security check: ensure within project root
|
|
123
|
+
try:
|
|
124
|
+
resolved.relative_to(self.project_root)
|
|
125
|
+
except ValueError:
|
|
126
|
+
raise ValueError(f"Path escapes project root: {file_path}")
|
|
127
|
+
|
|
128
|
+
return resolved
|
|
129
|
+
|
|
130
|
+
def _get_tool_description(self, tool: str, args: Dict[str, Any]) -> str:
|
|
131
|
+
"""Get human-readable description of tool operation."""
|
|
132
|
+
if tool == "read_file":
|
|
133
|
+
return f"Read file: {args.get('file_path', '?')}"
|
|
134
|
+
elif tool == "write_file":
|
|
135
|
+
return f"Write file: {args.get('file_path', '?')}"
|
|
136
|
+
elif tool == "edit_file":
|
|
137
|
+
return f"Edit file: {args.get('file_path', '?')}"
|
|
138
|
+
elif tool == "delete_file":
|
|
139
|
+
return f"Delete file: {args.get('file_path', '?')}"
|
|
140
|
+
elif tool == "shell":
|
|
141
|
+
return f"Run command: {args.get('command', '?')}"
|
|
142
|
+
elif tool == "list_files":
|
|
143
|
+
return f"List files: {args.get('path', '.')}"
|
|
144
|
+
elif tool == "search_files":
|
|
145
|
+
return f"Search files: {args.get('pattern', '?')}"
|
|
146
|
+
elif tool == "search_code":
|
|
147
|
+
return f"Search code index: {args.get('query', '?')}"
|
|
148
|
+
else:
|
|
149
|
+
return f"{tool}: {args}"
|
|
150
|
+
|
|
151
|
+
# Tool implementations
|
|
152
|
+
|
|
153
|
+
async def _read_file(
|
|
154
|
+
self,
|
|
155
|
+
file_path: str,
|
|
156
|
+
start_line: Optional[int] = None,
|
|
157
|
+
end_line: Optional[int] = None,
|
|
158
|
+
max_lines: Optional[int] = None,
|
|
159
|
+
) -> Dict[str, Any]:
|
|
160
|
+
"""Read file contents."""
|
|
161
|
+
path = self._resolve_path(file_path)
|
|
162
|
+
|
|
163
|
+
if not path.exists():
|
|
164
|
+
return {"error": f"File not found: {file_path}"}
|
|
165
|
+
|
|
166
|
+
if not path.is_file():
|
|
167
|
+
return {"error": f"Not a file: {file_path}"}
|
|
168
|
+
|
|
169
|
+
# Check file size
|
|
170
|
+
size = path.stat().st_size
|
|
171
|
+
if size > self.MAX_FILE_SIZE:
|
|
172
|
+
return {
|
|
173
|
+
"error": f"File too large: {size} bytes (max: {self.MAX_FILE_SIZE})",
|
|
174
|
+
"size": size,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
179
|
+
lines = content.splitlines()
|
|
180
|
+
total_lines = len(lines)
|
|
181
|
+
|
|
182
|
+
# Apply line range
|
|
183
|
+
if start_line is not None or end_line is not None:
|
|
184
|
+
start = (start_line or 1) - 1 # Convert to 0-based
|
|
185
|
+
end = end_line or total_lines
|
|
186
|
+
lines = lines[start:end]
|
|
187
|
+
|
|
188
|
+
# Apply max lines
|
|
189
|
+
max_l = max_lines or self.MAX_LINES
|
|
190
|
+
truncated = len(lines) > max_l
|
|
191
|
+
if truncated:
|
|
192
|
+
lines = lines[:max_l]
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
"content": "\n".join(lines),
|
|
196
|
+
"total_lines": total_lines,
|
|
197
|
+
"lines_returned": len(lines),
|
|
198
|
+
"truncated": truncated,
|
|
199
|
+
"file_path": str(path.relative_to(self.project_root)),
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
except UnicodeDecodeError:
|
|
203
|
+
return {"error": f"Cannot read binary file: {file_path}"}
|
|
204
|
+
except Exception as e:
|
|
205
|
+
return {"error": f"Read error: {e}"}
|
|
206
|
+
|
|
207
|
+
async def _write_file(
|
|
208
|
+
self,
|
|
209
|
+
file_path: str,
|
|
210
|
+
content: str,
|
|
211
|
+
create_directories: bool = True,
|
|
212
|
+
) -> Dict[str, Any]:
|
|
213
|
+
"""Write content to a file."""
|
|
214
|
+
path = self._resolve_path(file_path)
|
|
215
|
+
|
|
216
|
+
# Create parent directories if needed
|
|
217
|
+
if create_directories:
|
|
218
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
# Check if file exists
|
|
222
|
+
existed = path.exists()
|
|
223
|
+
old_content = path.read_text() if existed else None
|
|
224
|
+
|
|
225
|
+
# Write new content
|
|
226
|
+
path.write_text(content, encoding="utf-8")
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
"success": True,
|
|
230
|
+
"file_path": str(path.relative_to(self.project_root)),
|
|
231
|
+
"created": not existed,
|
|
232
|
+
"lines_written": len(content.splitlines()),
|
|
233
|
+
"bytes_written": len(content.encode("utf-8")),
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
except Exception as e:
|
|
237
|
+
return {"error": f"Write error: {e}"}
|
|
238
|
+
|
|
239
|
+
async def _edit_file(
|
|
240
|
+
self,
|
|
241
|
+
file_path: str,
|
|
242
|
+
search: str,
|
|
243
|
+
replace: str,
|
|
244
|
+
all_occurrences: bool = False,
|
|
245
|
+
) -> Dict[str, Any]:
|
|
246
|
+
"""Edit file with search/replace."""
|
|
247
|
+
path = self._resolve_path(file_path)
|
|
248
|
+
|
|
249
|
+
if not path.exists():
|
|
250
|
+
return {"error": f"File not found: {file_path}"}
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
content = path.read_text(encoding="utf-8")
|
|
254
|
+
|
|
255
|
+
# Count occurrences
|
|
256
|
+
count = content.count(search)
|
|
257
|
+
|
|
258
|
+
if count == 0:
|
|
259
|
+
return {
|
|
260
|
+
"error": "Search string not found in file",
|
|
261
|
+
"file_path": str(path.relative_to(self.project_root)),
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
# Perform replacement
|
|
265
|
+
if all_occurrences:
|
|
266
|
+
new_content = content.replace(search, replace)
|
|
267
|
+
replacements = count
|
|
268
|
+
else:
|
|
269
|
+
new_content = content.replace(search, replace, 1)
|
|
270
|
+
replacements = 1
|
|
271
|
+
|
|
272
|
+
path.write_text(new_content, encoding="utf-8")
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
"success": True,
|
|
276
|
+
"file_path": str(path.relative_to(self.project_root)),
|
|
277
|
+
"replacements": replacements,
|
|
278
|
+
"total_occurrences": count,
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
except Exception as e:
|
|
282
|
+
return {"error": f"Edit error: {e}"}
|
|
283
|
+
|
|
284
|
+
async def _delete_file(self, file_path: str) -> Dict[str, Any]:
|
|
285
|
+
"""Delete a file."""
|
|
286
|
+
path = self._resolve_path(file_path)
|
|
287
|
+
|
|
288
|
+
if not path.exists():
|
|
289
|
+
return {"error": f"File not found: {file_path}"}
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
if path.is_file():
|
|
293
|
+
path.unlink()
|
|
294
|
+
else:
|
|
295
|
+
return {"error": f"Not a file: {file_path}"}
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
"success": True,
|
|
299
|
+
"file_path": str(path.relative_to(self.project_root)),
|
|
300
|
+
"deleted": True,
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
except Exception as e:
|
|
304
|
+
return {"error": f"Delete error: {e}"}
|
|
305
|
+
|
|
306
|
+
async def _list_files(
|
|
307
|
+
self,
|
|
308
|
+
path: str = ".",
|
|
309
|
+
pattern: Optional[str] = None,
|
|
310
|
+
recursive: bool = True,
|
|
311
|
+
include_hidden: bool = False,
|
|
312
|
+
max_files: int = 500,
|
|
313
|
+
) -> Dict[str, Any]:
|
|
314
|
+
"""List files in a directory."""
|
|
315
|
+
# Handle absolute paths directly
|
|
316
|
+
path_obj = Path(path)
|
|
317
|
+
if path_obj.is_absolute():
|
|
318
|
+
dir_path = path_obj.resolve()
|
|
319
|
+
else:
|
|
320
|
+
dir_path = self._resolve_path(path)
|
|
321
|
+
|
|
322
|
+
if not dir_path.exists():
|
|
323
|
+
return {"error": f"Directory not found: {path}"}
|
|
324
|
+
|
|
325
|
+
if not dir_path.is_dir():
|
|
326
|
+
return {"error": f"Not a directory: {path}"}
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
files = []
|
|
330
|
+
dirs = []
|
|
331
|
+
|
|
332
|
+
if recursive:
|
|
333
|
+
items = dir_path.rglob("*")
|
|
334
|
+
else:
|
|
335
|
+
items = dir_path.iterdir()
|
|
336
|
+
|
|
337
|
+
for item in items:
|
|
338
|
+
# Skip hidden files unless requested
|
|
339
|
+
if not include_hidden and item.name.startswith("."):
|
|
340
|
+
continue
|
|
341
|
+
|
|
342
|
+
# Apply pattern filter
|
|
343
|
+
if pattern and not fnmatch.fnmatch(item.name, pattern):
|
|
344
|
+
continue
|
|
345
|
+
|
|
346
|
+
# Try relative to project_root first, then to dir_path
|
|
347
|
+
try:
|
|
348
|
+
rel_path = str(item.relative_to(self.project_root))
|
|
349
|
+
except ValueError:
|
|
350
|
+
# Path is outside project_root, use relative to dir_path
|
|
351
|
+
try:
|
|
352
|
+
rel_path = str(item.relative_to(dir_path))
|
|
353
|
+
except ValueError:
|
|
354
|
+
continue
|
|
355
|
+
|
|
356
|
+
if item.is_file():
|
|
357
|
+
files.append(rel_path)
|
|
358
|
+
elif item.is_dir():
|
|
359
|
+
dirs.append(rel_path)
|
|
360
|
+
|
|
361
|
+
# Limit results
|
|
362
|
+
if len(files) + len(dirs) >= max_files:
|
|
363
|
+
break
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
"files": sorted(files)[:max_files],
|
|
367
|
+
"directories": sorted(dirs)[:50],
|
|
368
|
+
"total_files": len(files),
|
|
369
|
+
"total_directories": len(dirs),
|
|
370
|
+
"truncated": len(files) >= max_files,
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
except Exception as e:
|
|
374
|
+
return {"error": f"List error: {e}"}
|
|
375
|
+
|
|
376
|
+
async def _search_files(
|
|
377
|
+
self,
|
|
378
|
+
pattern: str,
|
|
379
|
+
path: str = ".",
|
|
380
|
+
file_pattern: Optional[str] = None,
|
|
381
|
+
max_results: int = 100,
|
|
382
|
+
context_lines: int = 2,
|
|
383
|
+
) -> Dict[str, Any]:
|
|
384
|
+
"""Search for pattern in files."""
|
|
385
|
+
import re
|
|
386
|
+
|
|
387
|
+
dir_path = self._resolve_path(path)
|
|
388
|
+
|
|
389
|
+
if not dir_path.exists():
|
|
390
|
+
return {"error": f"Directory not found: {path}"}
|
|
391
|
+
|
|
392
|
+
try:
|
|
393
|
+
regex = re.compile(pattern, re.IGNORECASE)
|
|
394
|
+
except re.error as e:
|
|
395
|
+
return {"error": f"Invalid regex pattern: {e}"}
|
|
396
|
+
|
|
397
|
+
matches = []
|
|
398
|
+
files_searched = 0
|
|
399
|
+
|
|
400
|
+
try:
|
|
401
|
+
for file_path in dir_path.rglob("*"):
|
|
402
|
+
if not file_path.is_file():
|
|
403
|
+
continue
|
|
404
|
+
|
|
405
|
+
# Skip hidden and binary
|
|
406
|
+
if file_path.name.startswith("."):
|
|
407
|
+
continue
|
|
408
|
+
|
|
409
|
+
# Apply file pattern filter
|
|
410
|
+
if file_pattern and not fnmatch.fnmatch(file_path.name, file_pattern):
|
|
411
|
+
continue
|
|
412
|
+
|
|
413
|
+
# Skip large files
|
|
414
|
+
if file_path.stat().st_size > 1024 * 1024: # 1MB
|
|
415
|
+
continue
|
|
416
|
+
|
|
417
|
+
files_searched += 1
|
|
418
|
+
|
|
419
|
+
try:
|
|
420
|
+
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
|
421
|
+
lines = content.splitlines()
|
|
422
|
+
|
|
423
|
+
for i, line in enumerate(lines):
|
|
424
|
+
if regex.search(line):
|
|
425
|
+
# Get context
|
|
426
|
+
start = max(0, i - context_lines)
|
|
427
|
+
end = min(len(lines), i + context_lines + 1)
|
|
428
|
+
context = lines[start:end]
|
|
429
|
+
|
|
430
|
+
matches.append({
|
|
431
|
+
"file": str(file_path.relative_to(self.project_root)),
|
|
432
|
+
"line": i + 1,
|
|
433
|
+
"content": line.strip(),
|
|
434
|
+
"context": context,
|
|
435
|
+
})
|
|
436
|
+
|
|
437
|
+
if len(matches) >= max_results:
|
|
438
|
+
break
|
|
439
|
+
|
|
440
|
+
except (UnicodeDecodeError, PermissionError):
|
|
441
|
+
continue
|
|
442
|
+
|
|
443
|
+
if len(matches) >= max_results:
|
|
444
|
+
break
|
|
445
|
+
|
|
446
|
+
return {
|
|
447
|
+
"matches": matches,
|
|
448
|
+
"total_matches": len(matches),
|
|
449
|
+
"files_searched": files_searched,
|
|
450
|
+
"truncated": len(matches) >= max_results,
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
except Exception as e:
|
|
454
|
+
return {"error": f"Search error: {e}"}
|
|
455
|
+
|
|
456
|
+
async def _search_code(
|
|
457
|
+
self,
|
|
458
|
+
query: str,
|
|
459
|
+
hops: int = 1,
|
|
460
|
+
max_chunks: int = 10,
|
|
461
|
+
) -> Dict[str, Any]:
|
|
462
|
+
"""
|
|
463
|
+
Search code using BM25 + Knowledge Graph.
|
|
464
|
+
|
|
465
|
+
Uses the project's index created via /index command.
|
|
466
|
+
Returns relevant code chunks with their relationships.
|
|
467
|
+
"""
|
|
468
|
+
try:
|
|
469
|
+
from tarang.context import get_retriever
|
|
470
|
+
except ImportError:
|
|
471
|
+
return {
|
|
472
|
+
"error": "Context retrieval module not available. Run 'pip install tarang' to install.",
|
|
473
|
+
"indexed": False,
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
# Get retriever for this project
|
|
477
|
+
retriever = get_retriever(self.project_root)
|
|
478
|
+
|
|
479
|
+
if retriever is None or not retriever.is_ready:
|
|
480
|
+
return {
|
|
481
|
+
"error": "Project not indexed. Run '/index' command first to build the code index.",
|
|
482
|
+
"indexed": False,
|
|
483
|
+
"hint": "The /index command creates a searchable index of your codebase using BM25 and a Symbol Knowledge Graph.",
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
try:
|
|
487
|
+
# Execute search
|
|
488
|
+
result = retriever.retrieve(
|
|
489
|
+
query=query,
|
|
490
|
+
hops=min(hops, 2),
|
|
491
|
+
max_chunks=min(max_chunks, 20),
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
# Format response
|
|
495
|
+
return {
|
|
496
|
+
"success": True,
|
|
497
|
+
"indexed": True,
|
|
498
|
+
"query": query,
|
|
499
|
+
"chunks": [
|
|
500
|
+
{
|
|
501
|
+
"id": c.id,
|
|
502
|
+
"file": c.file,
|
|
503
|
+
"type": c.type,
|
|
504
|
+
"name": c.name,
|
|
505
|
+
"signature": c.signature,
|
|
506
|
+
"content": c.content,
|
|
507
|
+
"line_start": c.line_start,
|
|
508
|
+
"line_end": c.line_end,
|
|
509
|
+
}
|
|
510
|
+
for c in result.chunks
|
|
511
|
+
],
|
|
512
|
+
"signatures": result.signatures,
|
|
513
|
+
"graph": result.graph_context,
|
|
514
|
+
"stats": result.stats,
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
except Exception as e:
|
|
518
|
+
logger.exception("search_code error")
|
|
519
|
+
return {
|
|
520
|
+
"error": f"Search failed: {e}",
|
|
521
|
+
"indexed": True,
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async def _get_file_info(self, file_path: str) -> Dict[str, Any]:
|
|
525
|
+
"""Get file metadata."""
|
|
526
|
+
path = self._resolve_path(file_path)
|
|
527
|
+
|
|
528
|
+
if not path.exists():
|
|
529
|
+
return {"error": f"File not found: {file_path}"}
|
|
530
|
+
|
|
531
|
+
try:
|
|
532
|
+
stat = path.stat()
|
|
533
|
+
|
|
534
|
+
return {
|
|
535
|
+
"file_path": str(path.relative_to(self.project_root)),
|
|
536
|
+
"exists": True,
|
|
537
|
+
"is_file": path.is_file(),
|
|
538
|
+
"is_directory": path.is_dir(),
|
|
539
|
+
"size": stat.st_size,
|
|
540
|
+
"modified": stat.st_mtime,
|
|
541
|
+
"created": stat.st_ctime,
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
except Exception as e:
|
|
545
|
+
return {"error": f"Info error: {e}"}
|
|
546
|
+
|
|
547
|
+
async def _create_directory(
|
|
548
|
+
self,
|
|
549
|
+
path: str,
|
|
550
|
+
parents: bool = True,
|
|
551
|
+
) -> Dict[str, Any]:
|
|
552
|
+
"""Create a directory."""
|
|
553
|
+
dir_path = self._resolve_path(path)
|
|
554
|
+
|
|
555
|
+
try:
|
|
556
|
+
dir_path.mkdir(parents=parents, exist_ok=True)
|
|
557
|
+
|
|
558
|
+
return {
|
|
559
|
+
"success": True,
|
|
560
|
+
"path": str(dir_path.relative_to(self.project_root)),
|
|
561
|
+
"created": True,
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
except Exception as e:
|
|
565
|
+
return {"error": f"Create directory error: {e}"}
|
|
566
|
+
|
|
567
|
+
async def _shell(
|
|
568
|
+
self,
|
|
569
|
+
command: str,
|
|
570
|
+
cwd: Optional[str] = None,
|
|
571
|
+
timeout: Optional[int] = None,
|
|
572
|
+
env: Optional[Dict[str, str]] = None,
|
|
573
|
+
) -> Dict[str, Any]:
|
|
574
|
+
"""Execute a shell command."""
|
|
575
|
+
# Resolve working directory
|
|
576
|
+
if cwd:
|
|
577
|
+
work_dir = self._resolve_path(cwd)
|
|
578
|
+
else:
|
|
579
|
+
work_dir = self.project_root
|
|
580
|
+
|
|
581
|
+
if not work_dir.exists():
|
|
582
|
+
return {"error": f"Working directory not found: {cwd}"}
|
|
583
|
+
|
|
584
|
+
# Set timeout
|
|
585
|
+
cmd_timeout = timeout or self.SHELL_TIMEOUT
|
|
586
|
+
|
|
587
|
+
# Prepare environment
|
|
588
|
+
cmd_env = os.environ.copy()
|
|
589
|
+
if env:
|
|
590
|
+
cmd_env.update(env)
|
|
591
|
+
|
|
592
|
+
try:
|
|
593
|
+
# Run command
|
|
594
|
+
result = await asyncio.get_event_loop().run_in_executor(
|
|
595
|
+
None,
|
|
596
|
+
lambda: subprocess.run(
|
|
597
|
+
command,
|
|
598
|
+
shell=True,
|
|
599
|
+
cwd=work_dir,
|
|
600
|
+
capture_output=True,
|
|
601
|
+
timeout=cmd_timeout,
|
|
602
|
+
env=cmd_env,
|
|
603
|
+
),
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
stdout = result.stdout.decode("utf-8", errors="replace")
|
|
607
|
+
stderr = result.stderr.decode("utf-8", errors="replace")
|
|
608
|
+
|
|
609
|
+
# Truncate long output
|
|
610
|
+
max_output = 50000
|
|
611
|
+
stdout_truncated = len(stdout) > max_output
|
|
612
|
+
stderr_truncated = len(stderr) > max_output
|
|
613
|
+
|
|
614
|
+
if stdout_truncated:
|
|
615
|
+
stdout = stdout[:max_output] + "\n... (truncated)"
|
|
616
|
+
if stderr_truncated:
|
|
617
|
+
stderr = stderr[:max_output] + "\n... (truncated)"
|
|
618
|
+
|
|
619
|
+
return {
|
|
620
|
+
"success": result.returncode == 0,
|
|
621
|
+
"exit_code": result.returncode,
|
|
622
|
+
"stdout": stdout,
|
|
623
|
+
"stderr": stderr,
|
|
624
|
+
"command": command,
|
|
625
|
+
"cwd": str(work_dir.relative_to(self.project_root)),
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
except subprocess.TimeoutExpired:
|
|
629
|
+
return {
|
|
630
|
+
"error": f"Command timed out after {cmd_timeout}s",
|
|
631
|
+
"command": command,
|
|
632
|
+
"timeout": True,
|
|
633
|
+
}
|
|
634
|
+
except Exception as e:
|
|
635
|
+
return {
|
|
636
|
+
"error": f"Shell error: {e}",
|
|
637
|
+
"command": command,
|
|
638
|
+
}
|