vamp-cli 0.1.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.
Files changed (57) hide show
  1. vamp_cli/__init__.py +0 -0
  2. vamp_cli/agent/__init__.py +0 -0
  3. vamp_cli/agent/context_sync.py +72 -0
  4. vamp_cli/agent/graph.py +159 -0
  5. vamp_cli/agent/instructions.py +18 -0
  6. vamp_cli/agent/nodes.py +375 -0
  7. vamp_cli/agent/roles.py +8 -0
  8. vamp_cli/agent/runtime/__init__.py +0 -0
  9. vamp_cli/agent/runtime/runtime.py +133 -0
  10. vamp_cli/agent/state.py +55 -0
  11. vamp_cli/agent/status.py +17 -0
  12. vamp_cli/config/__init__.py +0 -0
  13. vamp_cli/config/loader.py +152 -0
  14. vamp_cli/config/schema.py +82 -0
  15. vamp_cli/llm/__init__.py +0 -0
  16. vamp_cli/llm/adapter.py +52 -0
  17. vamp_cli/llm/adapter_registry.py +26 -0
  18. vamp_cli/llm/debug_callbacks.py +18 -0
  19. vamp_cli/llm/router.py +247 -0
  20. vamp_cli/llm/usage.py +170 -0
  21. vamp_cli/logging_setup/logging_setup.py +37 -0
  22. vamp_cli/main.py +1023 -0
  23. vamp_cli/persistence/__init__.py +29 -0
  24. vamp_cli/persistence/compaction.py +372 -0
  25. vamp_cli/persistence/emitter.py +371 -0
  26. vamp_cli/persistence/event_log.py +303 -0
  27. vamp_cli/persistence/fork.py +81 -0
  28. vamp_cli/persistence/loader.py +616 -0
  29. vamp_cli/persistence/transcript.py +160 -0
  30. vamp_cli/prompts/prompts.py +182 -0
  31. vamp_cli/sandbox/__init__.py +0 -0
  32. vamp_cli/sandbox/base.py +22 -0
  33. vamp_cli/sandbox/config.py +16 -0
  34. vamp_cli/sandbox/docker.py +293 -0
  35. vamp_cli/sandbox/manager.py +31 -0
  36. vamp_cli/sandbox/result.py +14 -0
  37. vamp_cli/sandbox/terminal.py +151 -0
  38. vamp_cli/tools/__init__.py +0 -0
  39. vamp_cli/tools/base.py +44 -0
  40. vamp_cli/tools/diagnostic.py +120 -0
  41. vamp_cli/tools/execute_cmd.py +16 -0
  42. vamp_cli/tools/files.py +652 -0
  43. vamp_cli/tools/registry.py +163 -0
  44. vamp_cli/tools/tools_classes.py +622 -0
  45. vamp_cli/utils/__init__.py +0 -0
  46. vamp_cli/utils/error_codes.py +7 -0
  47. vamp_cli/utils/ignore.py +36 -0
  48. vamp_cli/utils/sha_256.py +32 -0
  49. vamp_cli/utils/token_tracker.py +31 -0
  50. vamp_cli/workspace/__init__.py +0 -0
  51. vamp_cli/workspace/workspace.py +16 -0
  52. vamp_cli-0.1.0.dist-info/METADATA +317 -0
  53. vamp_cli-0.1.0.dist-info/RECORD +57 -0
  54. vamp_cli-0.1.0.dist-info/WHEEL +5 -0
  55. vamp_cli-0.1.0.dist-info/entry_points.txt +2 -0
  56. vamp_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  57. vamp_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,652 @@
1
+ import os
2
+ import re
3
+ import json
4
+ import fnmatch
5
+ import subprocess
6
+ from typing import Any
7
+ from pathlib import Path
8
+ from vamp_cli.utils.ignore import IgnoreMatcher
9
+ from vamp_cli.workspace.workspace import Workspace
10
+ from vamp_cli.utils.error_codes import Error_codes
11
+ from vamp_cli.tools.diagnostic import DiagnosticTools
12
+
13
+ class FileTools():
14
+ def __init__(
15
+ self,
16
+ workspace: Workspace,
17
+ ignore: IgnoreMatcher,
18
+ diagnostic: DiagnosticTools
19
+ ) -> None:
20
+ self.workspace = workspace
21
+ self.ignore = ignore
22
+ self.diagnostic = diagnostic
23
+
24
+
25
+ def list_dir(
26
+ self,
27
+ path: str,
28
+ max_depth: int = 2
29
+ ) -> dict[str, str | list[tuple[str, str]]]:
30
+
31
+ res_list = []
32
+ _path = self.workspace.safe_path(path)
33
+
34
+ if _path == Error_codes.PATH_SCOPE_ERROR:
35
+ return {
36
+ "parent": path,
37
+ "error": Error_codes.PATH_SCOPE_ERROR.value
38
+ }
39
+
40
+ res_list = _walk_dir(root=_path, ignore=self.ignore, max_depth=max_depth, res=res_list)
41
+ return {
42
+ "parent_path": path,
43
+ "list": res_list
44
+ }
45
+
46
+
47
+ def read_file(
48
+ self,
49
+ path: str,
50
+ start: int | None = None,
51
+ end: int | None = None
52
+ ) -> dict[str, str | int]:
53
+ _path = self.workspace.safe_path(path)
54
+
55
+ if _path == Error_codes.PATH_SCOPE_ERROR:
56
+ return {
57
+ "parent": path,
58
+ "error": Error_codes.PATH_SCOPE_ERROR.value
59
+ }
60
+
61
+ if not Path(_path).is_file():
62
+ return {
63
+ "parent": path,
64
+ "error": Error_codes.NOT_FILE_ERROR.value
65
+ }
66
+
67
+ MAX_RANGE = 500
68
+ start_line = max(1, start) if start is not None else 1
69
+
70
+ if end is not None:
71
+ end_line = min(max(end, start_line), start_line + MAX_RANGE - 1)
72
+ else:
73
+ end_line = start_line + MAX_RANGE - 1
74
+
75
+ lines = []
76
+ try:
77
+ total_bytes = os.path.getsize(_path)
78
+ total_lines = 0
79
+ # TODO: v2: cache file metadata / line count
80
+ # invalidate cache after mutations
81
+ # possibly use more efficient random-access line indexing
82
+
83
+ with open(_path, 'r', encoding='utf-8', errors='replace') as f:
84
+ for curr_line, line in enumerate(f, start=1):
85
+ if start_line <= curr_line <= end_line:
86
+ lines.append(f"{curr_line}: {line}")
87
+ total_lines = curr_line
88
+
89
+ content = "".join(lines)
90
+
91
+ return {
92
+ "path": path,
93
+ "start": start_line,
94
+ "end": min(end_line, total_lines) if total_lines > 0 else end_line,
95
+ "total_line": total_lines,
96
+ "content": content,
97
+ "file_size_bytes": total_bytes
98
+ }
99
+
100
+ except FileNotFoundError:
101
+ return {
102
+ "parent": path,
103
+ "error": Error_codes.FILE_NOT_FOUND_ERROR.value
104
+ }
105
+ except Exception as e:
106
+ return {
107
+ "parent": path,
108
+ "error": f"Failed to read file: {str(e)}"
109
+ }
110
+
111
+ def read_many_files(
112
+ self,
113
+ paths: list[str],
114
+ start: int | None = None,
115
+ end: int | None = None
116
+ ) -> dict[str, list[dict[str, str | int]] | str]:
117
+ """
118
+ Read multiple files in a single call.
119
+
120
+ Accepts a list of file paths and optional line range parameters.
121
+ For each file path, validates it's within workspace scope.
122
+ Returns a structured result containing the content of all requested files
123
+ with their paths, line ranges, and any errors for files that couldn't be read.
124
+ """
125
+ results: list[dict[str, str | int]] = []
126
+
127
+ for path in paths:
128
+ _path = self.workspace.safe_path(path)
129
+
130
+ if _path == Error_codes.PATH_SCOPE_ERROR:
131
+ results.append({
132
+ "path": path,
133
+ "error": Error_codes.PATH_SCOPE_ERROR.value
134
+ })
135
+ continue
136
+
137
+ if not Path(_path).is_file():
138
+ results.append({
139
+ "path": path,
140
+ "error": Error_codes.NOT_FILE_ERROR.value
141
+ })
142
+ continue
143
+
144
+ MAX_RANGE = 500
145
+ start_line = max(1, start) if start is not None else 1
146
+
147
+ if end is not None:
148
+ end_line = min(max(end, start_line), start_line + MAX_RANGE - 1)
149
+ else:
150
+ end_line = start_line + MAX_RANGE - 1
151
+
152
+ lines = []
153
+ try:
154
+ total_bytes = os.path.getsize(_path)
155
+ total_lines = 0
156
+
157
+ with open(_path, 'r', encoding='utf-8', errors='replace') as f:
158
+ for curr_line, line in enumerate(f, start=1):
159
+ if start_line <= curr_line <= end_line:
160
+ lines.append(f"{curr_line}: {line}")
161
+ total_lines = curr_line
162
+
163
+ content = "".join(lines)
164
+
165
+ results.append({
166
+ "path": path,
167
+ "start": start_line,
168
+ "end": min(end_line, total_lines) if total_lines > 0 else end_line,
169
+ "total_line": total_lines,
170
+ "content": content,
171
+ "file_size_bytes": total_bytes
172
+ })
173
+
174
+ except FileNotFoundError:
175
+ results.append({
176
+ "path": path,
177
+ "error": Error_codes.FILE_NOT_FOUND_ERROR.value
178
+ })
179
+ except Exception as e:
180
+ results.append({
181
+ "path": path,
182
+ "error": f"Failed to read file: {str(e)}"
183
+ })
184
+
185
+ return {
186
+ "files": results
187
+ }
188
+
189
+ def write_file(
190
+ self,
191
+ path: str,
192
+ content: str,
193
+ overwrite: bool = False,
194
+ ) -> dict[str, Any]:
195
+ """
196
+ Creates a new file or overwrites an existing file.
197
+
198
+ If overwrite=False, an existing file is not modified.
199
+ If overwrite=True, the entire file is replaced.
200
+ """
201
+ _path = self.workspace.safe_path(path)
202
+
203
+ if _path == Error_codes.PATH_SCOPE_ERROR:
204
+ return {
205
+ "parent": path,
206
+ "error": Error_codes.PATH_SCOPE_ERROR.value,
207
+ }
208
+
209
+ file_path = Path(_path)
210
+
211
+ if file_path.exists() and file_path.is_dir():
212
+ return {
213
+ "path": path,
214
+ "success": False,
215
+ "error": "Path is a directory, not a file.",
216
+ }
217
+
218
+ was_existing = file_path.exists()
219
+
220
+ if was_existing and not overwrite:
221
+ return {
222
+ "path": path,
223
+ "success": False,
224
+ "error": (
225
+ "File already exists. "
226
+ "Set overwrite=true to replace the entire file."
227
+ ),
228
+ }
229
+
230
+ try:
231
+ file_path.parent.mkdir(parents=True, exist_ok=True)
232
+
233
+ file_path.write_text(
234
+ content,
235
+ encoding="utf-8",
236
+ )
237
+
238
+ syntax_status = self.diagnostic.check_file_syntax(str(file_path))
239
+
240
+ response = {
241
+ "path": path,
242
+ "success": True,
243
+ "created": not was_existing,
244
+ "overwritten": was_existing,
245
+ "bytes_written": len(content.encode("utf-8")),
246
+ }
247
+
248
+ if not syntax_status["valid"]:
249
+ response["syntax_warning"] = (
250
+ "Modification was applied, but created a syntax error: "
251
+ f"{syntax_status['diagnostics'][0]['message']} on line {syntax_status['diagnostics'][0]['line']}."
252
+ )
253
+
254
+ return response
255
+
256
+
257
+ except OSError as e:
258
+ return {
259
+ "path": path,
260
+ "success": False,
261
+ "error": f"Failed to write file: {str(e)}",
262
+ }
263
+
264
+ def apply_search_replace(
265
+ self,
266
+ file_path: str,
267
+ search: str,
268
+ replace: str
269
+ ) -> dict[str, Any]:
270
+
271
+ path = self.workspace.safe_path(file_path)
272
+
273
+ if path == Error_codes.PATH_SCOPE_ERROR:
274
+ return {
275
+ "parent": file_path,
276
+ "error": Error_codes.PATH_SCOPE_ERROR.value,
277
+ }
278
+
279
+
280
+ if not os.path.exists(path):
281
+ return {
282
+ "path": file_path,
283
+ "success": False,
284
+ "error": f"File {file_path} not found."
285
+ }
286
+
287
+ with open(path, 'r', encoding='utf-8') as f:
288
+ content = f.read()
289
+
290
+ # exact match
291
+ first = content.find(search)
292
+
293
+ if first != -1:
294
+ second = content.find(
295
+ search,
296
+ first + len(search),
297
+ )
298
+ if second != -1:
299
+ return {
300
+ "path": path,
301
+ "success": False,
302
+ "error": (
303
+ "Search string found multiple times. "
304
+ "Include more surrounding code to make it unique."
305
+ ),
306
+ }
307
+ new_content = (
308
+ content[:first]
309
+ + replace
310
+ + content[first + len(search):]
311
+ )
312
+ with open(path, 'w', encoding='utf-8') as f:
313
+ f.write(new_content)
314
+
315
+ syntax_status = self.diagnostic.check_file_syntax(path)
316
+
317
+ response = {
318
+ "path": path,
319
+ "success": True,
320
+ "match_type": "exact"
321
+ }
322
+
323
+ if not syntax_status["valid"]:
324
+ response["syntax_warning"] = (
325
+ "Modification was applied, but created a syntax error: "
326
+ f"{syntax_status['diagnostics'][0]['message']} on line {syntax_status['diagnostics'][0]['line']}."
327
+ )
328
+
329
+ return response
330
+
331
+ # Fuzzy Whitespace Fallback if exact matche is zero
332
+ search_words = search.split()
333
+ if not search_words:
334
+ return {
335
+ "path": path,
336
+ "success": False,
337
+ "error": "Search string is empty or only contains whitespace."
338
+ }
339
+
340
+ escaped_words = [re.escape(word) for word in search_words]
341
+
342
+ fuzzy_pattern = r'\s+'.join(escaped_words)
343
+
344
+ matches = list(re.finditer(fuzzy_pattern, content))
345
+
346
+ if len(matches) == 0:
347
+ return {
348
+ "path": path,
349
+ "success": False,
350
+ "error": "Search string not found. Ensure you are matching "
351
+ "the exact characters of the existing code."
352
+ }
353
+
354
+ if len(matches) > 1:
355
+ return {
356
+ "path": path,
357
+ "success": False,
358
+ "error": f"Fuzzy match found {len(matches)} times. "
359
+ "Include more surrounding lines to make your search block unique."
360
+ }
361
+
362
+ # exact one match found
363
+ match = matches[0]
364
+
365
+ new_content = content[:match.start()] + replace + content[match.end():]
366
+
367
+ with open(path, 'w', encoding='utf-8') as f:
368
+ f.write(new_content)
369
+
370
+ syntax_status = self.diagnostic.check_file_syntax(path)
371
+
372
+ response = {
373
+ "path": path,
374
+ "success": True,
375
+ "match_type": "exact_with_fuzzy_whitespace"
376
+ }
377
+
378
+ if not syntax_status["valid"]:
379
+ response["syntax_warning"] = (
380
+ "Modification was applied, but created a syntax error: "
381
+ f"{syntax_status['diagnostics'][0]['message']} on line {syntax_status['diagnostics'][0]['line']}."
382
+ )
383
+
384
+ return response
385
+
386
+
387
+
388
+ def file_search(
389
+ self,
390
+ query: str,
391
+ path: str = ".",
392
+ max_results: int = 100,
393
+ ) -> dict[str, Any]:
394
+ """
395
+ Finds files by filename or glob pattern like "config", "*.py", "*test*", "src/**/config.*"
396
+ Uses ripgrep's file listing, which respects .gitignore
397
+ """
398
+ _path = self.workspace.safe_path(path)
399
+
400
+ if _path == Error_codes.PATH_SCOPE_ERROR:
401
+ return {
402
+ "parent": path,
403
+ "error": Error_codes.PATH_SCOPE_ERROR.value,
404
+ }
405
+
406
+ is_glob = any(char in query for char in ["*", "?", "[", "]"])
407
+
408
+ if is_glob:
409
+ glob_pattern = query
410
+ else:
411
+ glob_pattern = f"*{query}*"
412
+
413
+ cmd = [
414
+ "rg",
415
+ "--files",
416
+ ]
417
+
418
+ cmd.extend([
419
+ "--glob",
420
+ glob_pattern,
421
+ str(_path),
422
+ ])
423
+
424
+ workspace_root = self.workspace.root
425
+ try:
426
+ result = subprocess.run(
427
+ cmd,
428
+ capture_output=True,
429
+ text=True,
430
+ check=False,
431
+ )
432
+ except FileNotFoundError:
433
+ return {
434
+ "error": (
435
+ "ripgrep (rg) command not found. "
436
+ "Please install ripgrep."
437
+ )
438
+ }
439
+
440
+ if result.returncode == 2:
441
+ return {
442
+ "error": f"rg execution failed: {result.stderr.strip()}"
443
+ }
444
+
445
+ matches: list[str] = []
446
+
447
+ for line in result.stdout.splitlines():
448
+ if len(matches) >= max_results:
449
+ break
450
+ try:
451
+ rel_path = Path(line).resolve().relative_to(workspace_root).as_posix()
452
+ except ValueError:
453
+ rel_path = Path(line).as_posix()
454
+
455
+ matches.append(rel_path)
456
+
457
+ return {
458
+ "query": query,
459
+ "count": len(matches),
460
+ "truncated": len(matches) >= max_results,
461
+ "matches": matches,
462
+ }
463
+
464
+
465
+ def ripgrep_search(
466
+ self,
467
+ query: str,
468
+ path: str = ".",
469
+ is_regex: bool = False,
470
+ file_pattern: str | None = None,
471
+ max_matches: int = 50,
472
+ ) -> dict[str, Any]:
473
+ """
474
+ Searches for text or regex across file contents using ripgrep (rg).
475
+
476
+ The path may be a file or directory.
477
+
478
+ Returns matching lines with file paths and line numbers.
479
+ """
480
+ _path = self.workspace.safe_path(path)
481
+
482
+ if _path == Error_codes.PATH_SCOPE_ERROR:
483
+ return {
484
+ "parent": path,
485
+ "error": Error_codes.PATH_SCOPE_ERROR.value,
486
+ }
487
+
488
+ cmd = [
489
+ "rg",
490
+ "--json",
491
+ "-n",
492
+ ]
493
+
494
+ if not is_regex:
495
+ cmd.extend(["-F", "-i"])
496
+
497
+ if file_pattern:
498
+ cmd.extend(["--glob", file_pattern])
499
+
500
+ cmd.extend([
501
+ "--",
502
+ query,
503
+ str(_path),
504
+ ])
505
+
506
+ results: list[dict[str, str]] = []
507
+ workspace_root = self.workspace.root
508
+ try:
509
+ with subprocess.Popen(
510
+ cmd,
511
+ stdout=subprocess.PIPE,
512
+ stderr=subprocess.PIPE,
513
+ text=True,
514
+ ) as process:
515
+
516
+ assert process.stdout is not None
517
+
518
+ for line in process.stdout:
519
+ if len(results) >= max_matches:
520
+ process.kill()
521
+ break
522
+
523
+ try:
524
+ event = json.loads(line)
525
+ except json.JSONDecodeError:
526
+ continue
527
+
528
+ if event.get("type") != "match":
529
+ continue
530
+
531
+ data = event.get("data", {})
532
+
533
+ path_data = data.get("path", {})
534
+ line_number = data.get("line_number")
535
+
536
+ file_path = path_data.get("text")
537
+
538
+ if not file_path or line_number is None:
539
+ continue
540
+
541
+ try:
542
+ rel_path = Path(file_path).resolve().relative_to(workspace_root).as_posix()
543
+ except ValueError:
544
+ rel_path = file_path
545
+
546
+ content_data = data.get("lines", {})
547
+ content = content_data.get("text", "")
548
+
549
+ results.append({
550
+ "file": rel_path,
551
+ "line": line_number,
552
+ "content": content.rstrip()[:250],
553
+ })
554
+
555
+ stderr = process.stderr.read() if process.stderr else ""
556
+
557
+ # Make sure the process has finished.
558
+ process.wait()
559
+
560
+ except FileNotFoundError:
561
+ return {
562
+ "error": (
563
+ "ripgrep (rg) command not found. "
564
+ "Please install ripgrep."
565
+ )
566
+ }
567
+ except Exception as e:
568
+ return {
569
+ "error": f"Failed processing ripgrep output: {str(e)}"
570
+ }
571
+
572
+ if process.returncode == 2:
573
+ return {
574
+ "error": f"rg execution failed: {stderr.strip()}"
575
+ }
576
+
577
+ return {
578
+ "query": query,
579
+ "total_matches": len(results),
580
+ "truncated": len(results) >= max_matches,
581
+ "matches": results,
582
+ }
583
+
584
+
585
+ def _walk_dir(
586
+ root: str,
587
+ ignore: IgnoreMatcher,
588
+ max_depth: int | None = None,
589
+ res: list[tuple[str, str]] | None = None
590
+ ) -> list[tuple[str, str]]:
591
+
592
+ if res is None:
593
+ res = []
594
+
595
+ _root = Path(root).resolve()
596
+ stack: list[tuple[str, Path, int, list[str]|None]] = [("entry", _root, 0, None)]
597
+
598
+ while stack:
599
+ event, path, depth, patterns = stack.pop()
600
+
601
+ if event == "exit":
602
+ if patterns is not None:
603
+ ignore.remove(patterns)
604
+ continue
605
+
606
+ # entry
607
+ if path.is_symlink():
608
+ continue
609
+
610
+ local_patterns = _read_ignore_file(str(path))
611
+ if local_patterns:
612
+ ignore.add(local_patterns)
613
+
614
+ if ignore.should_ignore(str(path)):
615
+ if local_patterns:
616
+ ignore.remove(local_patterns)
617
+ continue
618
+
619
+ if str(path) != root:
620
+ res.append((str(path), "directory" if path.is_dir() else "file"))
621
+
622
+ if not path.is_dir() or (max_depth is not None and depth >= max_depth):
623
+ if local_patterns:
624
+ ignore.remove(local_patterns)
625
+ continue
626
+
627
+ # schedule exit as after this all children of this 'path' will be pushed and popped before this scheduled exit
628
+ stack.append(("exit", path, depth, local_patterns))
629
+
630
+ children = path.iterdir()
631
+
632
+ for child in children:
633
+ stack.append(("entry", child, depth+1, None))
634
+
635
+ return res
636
+
637
+
638
+ def _read_ignore_file(path: str) -> list[str] | None:
639
+ pattern_list: list[str] = []
640
+ gitignore = Path(path).resolve() / ".gitignore"
641
+ if gitignore.is_file():
642
+ with open(gitignore, "r") as f:
643
+ content = f.readlines()
644
+ for line in content:
645
+ line = line.strip()
646
+ if not line or line.startswith("#"):
647
+ continue
648
+ else:
649
+ pattern_list.append(line)
650
+ return pattern_list
651
+ else:
652
+ return None