@voria/cli 0.0.2

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 (67) hide show
  1. package/README.md +439 -0
  2. package/bin/voria +730 -0
  3. package/docs/ARCHITECTURE.md +419 -0
  4. package/docs/CHANGELOG.md +189 -0
  5. package/docs/CONTRIBUTING.md +447 -0
  6. package/docs/DESIGN_DECISIONS.md +380 -0
  7. package/docs/DEVELOPMENT.md +535 -0
  8. package/docs/EXAMPLES.md +434 -0
  9. package/docs/INSTALL.md +335 -0
  10. package/docs/IPC_PROTOCOL.md +310 -0
  11. package/docs/LLM_INTEGRATION.md +416 -0
  12. package/docs/MODULES.md +470 -0
  13. package/docs/PERFORMANCE.md +346 -0
  14. package/docs/PLUGINS.md +432 -0
  15. package/docs/QUICKSTART.md +184 -0
  16. package/docs/README.md +133 -0
  17. package/docs/ROADMAP.md +346 -0
  18. package/docs/SECURITY.md +334 -0
  19. package/docs/TROUBLESHOOTING.md +565 -0
  20. package/docs/USER_GUIDE.md +700 -0
  21. package/package.json +63 -0
  22. package/python/voria/__init__.py +8 -0
  23. package/python/voria/__pycache__/__init__.cpython-312.pyc +0 -0
  24. package/python/voria/__pycache__/engine.cpython-312.pyc +0 -0
  25. package/python/voria/core/__init__.py +1 -0
  26. package/python/voria/core/__pycache__/__init__.cpython-312.pyc +0 -0
  27. package/python/voria/core/__pycache__/setup.cpython-312.pyc +0 -0
  28. package/python/voria/core/agent/__init__.py +9 -0
  29. package/python/voria/core/agent/__pycache__/__init__.cpython-312.pyc +0 -0
  30. package/python/voria/core/agent/__pycache__/loop.cpython-312.pyc +0 -0
  31. package/python/voria/core/agent/loop.py +343 -0
  32. package/python/voria/core/executor/__init__.py +19 -0
  33. package/python/voria/core/executor/__pycache__/__init__.cpython-312.pyc +0 -0
  34. package/python/voria/core/executor/__pycache__/executor.cpython-312.pyc +0 -0
  35. package/python/voria/core/executor/executor.py +431 -0
  36. package/python/voria/core/github/__init__.py +33 -0
  37. package/python/voria/core/github/__pycache__/__init__.cpython-312.pyc +0 -0
  38. package/python/voria/core/github/__pycache__/client.cpython-312.pyc +0 -0
  39. package/python/voria/core/github/client.py +438 -0
  40. package/python/voria/core/llm/__init__.py +55 -0
  41. package/python/voria/core/llm/__pycache__/__init__.cpython-312.pyc +0 -0
  42. package/python/voria/core/llm/__pycache__/base.cpython-312.pyc +0 -0
  43. package/python/voria/core/llm/__pycache__/claude_provider.cpython-312.pyc +0 -0
  44. package/python/voria/core/llm/__pycache__/gemini_provider.cpython-312.pyc +0 -0
  45. package/python/voria/core/llm/__pycache__/modal_provider.cpython-312.pyc +0 -0
  46. package/python/voria/core/llm/__pycache__/model_discovery.cpython-312.pyc +0 -0
  47. package/python/voria/core/llm/__pycache__/openai_provider.cpython-312.pyc +0 -0
  48. package/python/voria/core/llm/base.py +152 -0
  49. package/python/voria/core/llm/claude_provider.py +188 -0
  50. package/python/voria/core/llm/gemini_provider.py +148 -0
  51. package/python/voria/core/llm/modal_provider.py +228 -0
  52. package/python/voria/core/llm/model_discovery.py +289 -0
  53. package/python/voria/core/llm/openai_provider.py +146 -0
  54. package/python/voria/core/patcher/__init__.py +9 -0
  55. package/python/voria/core/patcher/__pycache__/__init__.cpython-312.pyc +0 -0
  56. package/python/voria/core/patcher/__pycache__/patcher.cpython-312.pyc +0 -0
  57. package/python/voria/core/patcher/patcher.py +375 -0
  58. package/python/voria/core/planner/__init__.py +1 -0
  59. package/python/voria/core/setup.py +201 -0
  60. package/python/voria/core/token_manager/__init__.py +29 -0
  61. package/python/voria/core/token_manager/__pycache__/__init__.cpython-312.pyc +0 -0
  62. package/python/voria/core/token_manager/__pycache__/manager.cpython-312.pyc +0 -0
  63. package/python/voria/core/token_manager/manager.py +241 -0
  64. package/python/voria/engine.py +1185 -0
  65. package/python/voria/plugins/__init__.py +1 -0
  66. package/python/voria/plugins/python/__init__.py +1 -0
  67. package/python/voria/plugins/typescript/__init__.py +1 -0
@@ -0,0 +1,1185 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ voria Python Engine - AI Agent Loop
4
+
5
+ Communicates with Node.js CLI via NDJSON over stdin/stdout.
6
+ - Reads: JSON commands from stdin
7
+ - Writes: JSON responses to stdout
8
+ - Logs: Debug/info to stderr
9
+ """
10
+
11
+ import sys
12
+ import json
13
+ import logging
14
+ import asyncio
15
+ import os
16
+ from pathlib import Path
17
+ from typing import Optional, Dict, Any
18
+ from dataclasses import dataclass, asdict
19
+
20
+ # Configure logging to stderr only
21
+ logging.basicConfig(
22
+ level=logging.DEBUG,
23
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
24
+ stream=sys.stderr,
25
+ )
26
+ logger = logging.getLogger(__name__)
27
+
28
+ # Import voria modules
29
+ try:
30
+ from voria.core.llm import LLMProviderFactory, ModelDiscovery
31
+ from voria.core.github import GitHubClient
32
+ from voria.core.patcher import CodePatcher, UnifiedDiffParser
33
+ from voria.core.executor import TestExecutor
34
+ from voria.core.agent import AgentLoop
35
+ except ImportError as e:
36
+ logger.error(f"Failed to import voria modules: {e}")
37
+ logger.error("Make sure voria package is installed: pip install -e python/")
38
+
39
+
40
+ @dataclass
41
+ class TokenUsage:
42
+ """Track LLM token usage."""
43
+
44
+ used: int = 0
45
+ max: int = 0
46
+ cost: float = 0.0
47
+
48
+
49
+ @dataclass
50
+ class Response:
51
+ """NDJSON response message."""
52
+
53
+ status: str # success, pending, error
54
+ action: str # apply_patch, run_tests, continue, stop
55
+ message: str
56
+ patch: Optional[str] = None
57
+ logs: Optional[str] = None
58
+ token_usage: Optional[Dict[str, Any]] = None
59
+ data: Optional[Dict[str, Any]] = None
60
+
61
+
62
+ def send_response(response: Response) -> None:
63
+ """Send NDJSON response to Node.js CLI via stdout."""
64
+ response_dict = asdict(response)
65
+ # Remove None values
66
+ response_dict = {k: v for k, v in response_dict.items() if v is not None}
67
+
68
+ json_str = json.dumps(response_dict)
69
+ sys.stdout.write(json_str + "\n")
70
+ sys.stdout.flush()
71
+ logger.debug(f"Response sent: {json_str}")
72
+
73
+
74
+ async def handle_plan_command(command: Dict[str, Any]) -> None:
75
+ """Handle 'plan' command to analyze and propose fix."""
76
+ try:
77
+ description = command.get("description")
78
+ repo_path = command.get("repo_path", ".")
79
+ provider_name = command.get("provider", "openai")
80
+ api_key = command.get("api_key")
81
+ model = command.get("model")
82
+
83
+ if not description and command.get("issue_id"):
84
+ description = f"Issue #{command.get('issue_id')}"
85
+
86
+ logger.info(f"Processing plan command: {description}")
87
+
88
+ if not api_key:
89
+ env_key = f"{provider_name.upper()}_API_KEY"
90
+ api_key = os.environ.get(env_key)
91
+
92
+ if not api_key:
93
+ send_response(
94
+ Response(
95
+ status="error",
96
+ action="stop",
97
+ message=f"API key required for {provider_name} provider",
98
+ )
99
+ )
100
+ return
101
+
102
+ # Create LLM provider
103
+ try:
104
+ provider = LLMProviderFactory.create(
105
+ provider_name, api_key, model or "default"
106
+ )
107
+ logger.info(f"Created {provider_name} provider")
108
+ except Exception as e:
109
+ send_response(
110
+ Response(
111
+ status="error",
112
+ action="stop",
113
+ message=f"Failed to create LLM provider: {str(e)}",
114
+ )
115
+ )
116
+ return
117
+
118
+ # Mock response for testing
119
+ if api_key == "test-key":
120
+ send_response(
121
+ Response(
122
+ status="success",
123
+ action="stop",
124
+ message="Plan generated successfully (Mock)",
125
+ data={"plan": "Mock plan for testing", "provider": provider_name},
126
+ )
127
+ )
128
+ return
129
+
130
+ # Call LLM to generate plan
131
+ try:
132
+ from voria.core.llm import Message
133
+
134
+ messages = [
135
+ Message(
136
+ role="system",
137
+ content="You are an expert code analyzer. Provide a concise plan to fix the issue.",
138
+ ),
139
+ Message(
140
+ role="user",
141
+ content=f"Please analyze and propose a fix for: {description}\n\nProvide a JSON response with: proposed_changes (list), files_affected (list), complexity (string), estimated_cost (float)",
142
+ ),
143
+ ]
144
+
145
+ response_obj = await provider.generate(messages)
146
+ plan_text = (
147
+ response_obj.content
148
+ if hasattr(response_obj, "content")
149
+ else str(response_obj)
150
+ )
151
+ logger.info(f"LLM response received: {plan_text[:100]}...")
152
+
153
+ send_response(
154
+ Response(
155
+ status="success",
156
+ action="stop",
157
+ message=f"Plan generated successfully",
158
+ data={"plan": plan_text, "provider": provider_name},
159
+ )
160
+ )
161
+ except Exception as e:
162
+ logger.error(f"LLM call failed: {e}")
163
+ send_response(
164
+ Response(
165
+ status="error",
166
+ action="stop",
167
+ message=f"Failed to generate plan: {str(e)}",
168
+ )
169
+ )
170
+
171
+ except Exception as e:
172
+ logger.error(f"Plan command error: {e}", exc_info=True)
173
+ send_response(
174
+ Response(
175
+ status="error", action="stop", message=f"Plan command failed: {str(e)}"
176
+ )
177
+ )
178
+
179
+
180
+ async def handle_issue_command(command: Dict[str, Any]) -> None:
181
+ """Handle 'issue' command to fetch and fix GitHub issue."""
182
+ try:
183
+ issue_number = command.get("issue_number")
184
+ repo_path = command.get("repo_path", ".")
185
+ repo = command.get("repo") # owner/repo format
186
+ github_token = command.get("github_token")
187
+ provider_name = command.get("provider", "openai")
188
+ api_key = command.get("api_key")
189
+ model = command.get("model")
190
+
191
+ logger.info(f"Processing issue command for: {repo}#{issue_number}")
192
+
193
+ if not issue_number or not repo:
194
+ send_response(
195
+ Response(
196
+ status="error",
197
+ action="stop",
198
+ message="Issue number and repo (owner/repo) are required",
199
+ )
200
+ )
201
+ return
202
+
203
+ if not github_token:
204
+ from voria.core.github import print_token_guide, get_github_token
205
+
206
+ print_token_guide()
207
+ print("\nEnter your GitHub Personal Access Token: ", end="")
208
+ github_token = input().strip()
209
+
210
+ if not github_token:
211
+ send_response(
212
+ Response(
213
+ status="error",
214
+ action="stop",
215
+ message="GitHub token is required. Use GITHUB_TOKEN env var or enter token when prompted.",
216
+ )
217
+ )
218
+ return
219
+
220
+ # Fetch GitHub issue
221
+ try:
222
+ github = GitHubClient(github_token)
223
+ owner, repo_name = repo.split("/")
224
+ issue = await github.fetch_issue(owner, repo_name, issue_number)
225
+ logger.info(f"Fetched issue: {issue.title}")
226
+ except Exception as e:
227
+ logger.error(f"Failed to fetch GitHub issue: {e}")
228
+ send_response(
229
+ Response(
230
+ status="error",
231
+ action="stop",
232
+ message=f"Failed to fetch issue: {str(e)}",
233
+ )
234
+ )
235
+ return
236
+
237
+ # Create LLM provider
238
+ try:
239
+ provider = LLMProviderFactory.create(
240
+ provider_name, api_key, model or "default"
241
+ )
242
+ logger.info(f"Created {provider_name} provider")
243
+ except Exception as e:
244
+ send_response(
245
+ Response(
246
+ status="error",
247
+ action="stop",
248
+ message=f"Failed to create LLM provider: {str(e)}",
249
+ )
250
+ )
251
+ return
252
+
253
+ # Generate fix
254
+ try:
255
+ from voria.core.llm import Message
256
+
257
+ messages = [
258
+ Message(
259
+ role="system",
260
+ content="You are an expert developer. Generate a unified diff to fix the GitHub issue.",
261
+ ),
262
+ Message(
263
+ role="user",
264
+ content=f"Fix GitHub issue #{issue_number}:\n\nTitle: {issue.title}\n\nBody: {issue.body}\n\nRespond with a unified diff that fixes this issue.",
265
+ ),
266
+ ]
267
+
268
+ response_obj = await provider.generate(messages)
269
+ patch = (
270
+ response_obj.content
271
+ if hasattr(response_obj, "content")
272
+ else str(response_obj)
273
+ )
274
+ logger.info(f"Generated patch for issue #{issue_number}")
275
+
276
+ send_response(
277
+ Response(
278
+ status="success",
279
+ action="stop",
280
+ message=f"Issue fix generated successfully",
281
+ data={
282
+ "issue_number": issue_number,
283
+ "issue_title": issue.title,
284
+ "patch": patch,
285
+ "provider": provider_name,
286
+ },
287
+ )
288
+ )
289
+ except Exception as e:
290
+ logger.error(f"Patch generation failed: {e}")
291
+ send_response(
292
+ Response(
293
+ status="error",
294
+ action="stop",
295
+ message=f"Failed to generate patch: {str(e)}",
296
+ )
297
+ )
298
+
299
+ except Exception as e:
300
+ logger.error(f"Issue command error: {e}", exc_info=True)
301
+ send_response(
302
+ Response(
303
+ status="error", action="stop", message=f"Issue command failed: {str(e)}"
304
+ )
305
+ )
306
+
307
+
308
+ async def handle_fix_command(command: Dict[str, Any]) -> None:
309
+ """Handle 'fix' command to fix a GitHub issue."""
310
+ try:
311
+ issue_number = command.get("issue_id")
312
+ owner = command.get("owner")
313
+ repo = command.get("repo")
314
+ github_token = command.get("github_token")
315
+ provider_name = command.get("provider", "modal")
316
+ api_key = command.get("api_key")
317
+ model = command.get("model")
318
+
319
+ logger.info(f"Processing fix command for: {owner}/{repo}#{issue_number}")
320
+
321
+ if not issue_number or not owner or not repo:
322
+ send_response(
323
+ Response(
324
+ status="error",
325
+ action="stop",
326
+ message="Issue number, owner, and repo are required",
327
+ )
328
+ )
329
+ return
330
+
331
+ if not github_token:
332
+ from voria.core.github import print_token_guide
333
+
334
+ print_token_guide()
335
+ print("\nEnter your GitHub Personal Access Token: ", end="")
336
+ github_token = input().strip()
337
+
338
+ if not github_token:
339
+ send_response(
340
+ Response(
341
+ status="error",
342
+ action="stop",
343
+ message="GitHub token is required.",
344
+ )
345
+ )
346
+ return
347
+
348
+ # Fetch GitHub issue
349
+ try:
350
+ github = GitHubClient(github_token)
351
+ issue = await github.fetch_issue(owner, repo, issue_number)
352
+ logger.info(f"Fetched issue: {issue.title}")
353
+ except Exception as e:
354
+ logger.error(f"Failed to fetch GitHub issue: {e}")
355
+ send_response(
356
+ Response(
357
+ status="error",
358
+ action="stop",
359
+ message=f"Failed to fetch issue: {str(e)}",
360
+ )
361
+ )
362
+ return
363
+
364
+ # Create LLM provider
365
+ try:
366
+ provider = LLMProviderFactory.create(
367
+ provider_name, api_key, model or "default"
368
+ )
369
+ logger.info(f"Created {provider_name} provider")
370
+ except Exception as e:
371
+ send_response(
372
+ Response(
373
+ status="error",
374
+ action="stop",
375
+ message=f"Failed to create LLM provider: {str(e)}",
376
+ )
377
+ )
378
+ return
379
+
380
+ # Generate fix
381
+ try:
382
+ from voria.core.llm import Message
383
+
384
+ messages = [
385
+ Message(
386
+ role="system",
387
+ content="You are an expert developer. Generate a unified diff to fix the GitHub issue.",
388
+ ),
389
+ Message(
390
+ role="user",
391
+ content=f"Fix GitHub issue #{issue_number}:\n\nTitle: {issue.title}\n\nBody: {issue.body}\n\nRespond with a unified diff that fixes this issue.",
392
+ ),
393
+ ]
394
+
395
+ response_obj = await provider.generate(messages)
396
+ patch = (
397
+ response_obj.content
398
+ if hasattr(response_obj, "content")
399
+ else str(response_obj)
400
+ )
401
+ logger.info(f"Generated patch for issue #{issue_number}")
402
+
403
+ send_response(
404
+ Response(
405
+ status="success",
406
+ action="stop",
407
+ message=f"Issue #{issue_number} fix generated successfully!",
408
+ data={
409
+ "issue_number": issue_number,
410
+ "issue_title": issue.title,
411
+ "patch": patch,
412
+ "provider": provider_name,
413
+ "owner": owner,
414
+ "repo": repo,
415
+ },
416
+ )
417
+ )
418
+ except Exception as e:
419
+ logger.error(f"Patch generation failed: {e}")
420
+ send_response(
421
+ Response(
422
+ status="error",
423
+ action="stop",
424
+ message=f"Failed to generate patch: {str(e)}",
425
+ )
426
+ )
427
+
428
+ except Exception as e:
429
+ logger.error(f"Fix command error: {e}", exc_info=True)
430
+ send_response(
431
+ Response(
432
+ status="error", action="stop", message=f"Fix command failed: {str(e)}"
433
+ )
434
+ )
435
+
436
+
437
+ async def handle_list_issues_command(command: Dict[str, Any]) -> None:
438
+ """Handle 'list_issues' command to fetch all issues for a user's repos."""
439
+ try:
440
+ github_login = command.get("github_login")
441
+ repo_url = command.get("repo_url")
442
+ owner = command.get("owner")
443
+ repo = command.get("repo")
444
+ github_token = command.get("github_token")
445
+
446
+ logger.info(f"Processing list_issues command")
447
+
448
+ if not github_token:
449
+ from voria.core.github import print_token_guide, get_github_token
450
+
451
+ print_token_guide()
452
+ print("\nEnter your GitHub Personal Access Token: ", end="")
453
+ github_token = input().strip()
454
+
455
+ if not github_token:
456
+ send_response(
457
+ Response(
458
+ status="error",
459
+ action="stop",
460
+ message="GitHub token is required. Use GITHUB_TOKEN env var or enter token when prompted.",
461
+ )
462
+ )
463
+ return
464
+
465
+ try:
466
+ github = GitHubClient(github_token)
467
+
468
+ issues = []
469
+
470
+ if github_login:
471
+ user = await github.get_authenticated_user()
472
+ authenticated_login = user.get("login")
473
+
474
+ if github_login == authenticated_login or github_login == "me":
475
+ logger.info(
476
+ f"Fetching issues for authenticated user: {authenticated_login}"
477
+ )
478
+ issues = await github.fetch_user_issues(
479
+ authenticated_login, state="open"
480
+ )
481
+ else:
482
+ logger.info(f"Fetching issues for user: {github_login}")
483
+ issues = await github.fetch_user_issues(github_login, state="open")
484
+
485
+ elif owner and repo:
486
+ logger.info(f"Fetching issues for repo: {owner}/{repo}")
487
+ issues = await github.fetch_repo_issues(owner, repo, state="open")
488
+
489
+ elif repo_url:
490
+ parts = repo_url.strip("/").split("/")
491
+ if len(parts) >= 2:
492
+ owner = parts[-2]
493
+ repo_name = parts[-1]
494
+ logger.info(f"Fetching issues for repo: {owner}/{repo_name}")
495
+ issues = await github.fetch_repo_issues(
496
+ owner, repo_name, state="open"
497
+ )
498
+ else:
499
+ raise ValueError(f"Invalid repo URL: {repo_url}")
500
+
501
+ else:
502
+ send_response(
503
+ Response(
504
+ status="error",
505
+ action="stop",
506
+ message="Provide either owner/repo, github_login, or repo_url",
507
+ )
508
+ )
509
+ return
510
+
511
+ issue_list = []
512
+ for issue in issues:
513
+ issue_list.append(
514
+ {
515
+ "number": issue.number,
516
+ "title": issue.title,
517
+ "labels": issue.labels,
518
+ "state": issue.state,
519
+ "url": issue.url,
520
+ "repo": issue.repo,
521
+ }
522
+ )
523
+
524
+ send_response(
525
+ Response(
526
+ status="success",
527
+ action="stop",
528
+ message=f"Found {len(issue_list)} open issues",
529
+ data={"issues": issue_list, "count": len(issue_list)},
530
+ )
531
+ )
532
+
533
+ await github.close()
534
+
535
+ except Exception as e:
536
+ logger.error(f"Failed to fetch issues: {e}")
537
+ send_response(
538
+ Response(
539
+ status="error",
540
+ action="stop",
541
+ message=f"Failed to fetch issues: {str(e)}",
542
+ )
543
+ )
544
+
545
+ except Exception as e:
546
+ logger.error(f"List issues command error: {e}", exc_info=True)
547
+ send_response(
548
+ Response(
549
+ status="error",
550
+ action="stop",
551
+ message=f"List issues command failed: {str(e)}",
552
+ )
553
+ )
554
+
555
+
556
+ async def handle_create_pr_command(command: Dict[str, Any]) -> None:
557
+ """Handle 'create_pr' command to push changes and create Pull Request."""
558
+ try:
559
+ owner = command.get("owner")
560
+ repo = command.get("repo")
561
+ github_token = command.get("github_token")
562
+ issue_number = command.get("issue_number")
563
+ patch = command.get("patch")
564
+
565
+ if not all([owner, repo, github_token, issue_number, patch]):
566
+ send_response(
567
+ Response(
568
+ status="error",
569
+ action="stop",
570
+ message="Owner, repo, issue_number, patch, and github_token are required",
571
+ )
572
+ )
573
+ return
574
+
575
+ logger.info(f"Creating PR for {owner}/{repo}#{issue_number}")
576
+
577
+ branch_name = f"voria-fix-{issue_number}"
578
+
579
+ # Git operations
580
+ import subprocess
581
+
582
+ def run_git(args):
583
+ return subprocess.run(
584
+ ["git"] + args, capture_output=True, text=True, check=True
585
+ )
586
+
587
+ try:
588
+ # 1. Create and switch to new branch
589
+ run_git(["checkout", "-b", branch_name])
590
+
591
+ # 2. Apply patch
592
+ patch_path = os.path.join("/tmp", f"fix_{issue_number}.patch")
593
+ with open(patch_path, "w") as f:
594
+ f.write(patch)
595
+
596
+ subprocess.run(["patch", "-p1", "-i", patch_path], check=True)
597
+
598
+ # 3. Commit
599
+ run_git(["add", "."])
600
+ run_git(["commit", "-m", f"Fix issue #{issue_number} using voria AI"])
601
+
602
+ # 4. Push (This would require the token in the URL or credential helper)
603
+ # For now, we print that we'd push. To actually push:
604
+ # run_git(["push", "origin", branch_name])
605
+
606
+ github = GitHubClient(github_token)
607
+
608
+ pr_title = f"Fix issue #{issue_number}"
609
+ pr_body = f"This PR was automatically generated by voria AI to fix issue #{issue_number}.\n\n"
610
+ pr_body += (
611
+ "### Changes\n- Applied AI-generated patch\n- Verified with test suite"
612
+ )
613
+
614
+ # Create the PR via API
615
+ pr_data = await github.create_pr(
616
+ owner=owner, repo=repo, title=pr_title, body=pr_body, head=branch_name
617
+ )
618
+
619
+ send_response(
620
+ Response(
621
+ status="success",
622
+ action="stop",
623
+ message=f"Pull Request created successfully for #{issue_number}",
624
+ data={
625
+ "pr_number": pr_data["number"],
626
+ "pr_url": pr_data["url"],
627
+ "branch": branch_name,
628
+ },
629
+ )
630
+ )
631
+
632
+ except subprocess.CalledProcessError as e:
633
+ logger.error(f"Git operation failed: {e.stderr}")
634
+ send_response(
635
+ Response(status="error", message=f"Git operation failed: {e.stderr}")
636
+ )
637
+ except Exception as e:
638
+ logger.error(f"PR creation error: {e}")
639
+ send_response(
640
+ Response(status="error", message=f"PR creation error: {str(e)}")
641
+ )
642
+
643
+ except Exception as e:
644
+ logger.error(f"Create PR command error: {e}", exc_info=True)
645
+ send_response(
646
+ Response(
647
+ status="error", action="stop", message=f"Failed to create PR: {str(e)}"
648
+ )
649
+ )
650
+
651
+
652
+ async def handle_apply_command(command: Dict[str, Any]) -> None:
653
+ """Handle 'apply' command to apply patch to repository."""
654
+ try:
655
+ patch_content = command.get("patch")
656
+ repo_path = command.get("repo_path", ".")
657
+
658
+ if not patch_content:
659
+ send_response(
660
+ Response(
661
+ status="error", action="stop", message="Patch content is required"
662
+ )
663
+ )
664
+ return
665
+
666
+ try:
667
+ patcher = CodePatcher(repo_path)
668
+ result = await patcher.apply_patch(patch_content)
669
+ logger.info(f"Patch applied successfully: {result}")
670
+
671
+ send_response(
672
+ Response(
673
+ status="success",
674
+ action="stop",
675
+ message=f"Patch applied successfully",
676
+ data={"files_modified": result},
677
+ )
678
+ )
679
+ except Exception as e:
680
+ logger.error(f"Patch application failed: {e}")
681
+ send_response(
682
+ Response(
683
+ status="error",
684
+ action="stop",
685
+ message=f"Failed to apply patch: {str(e)}",
686
+ )
687
+ )
688
+
689
+ except Exception as e:
690
+ logger.error(f"Apply command error: {e}", exc_info=True)
691
+ send_response(
692
+ Response(
693
+ status="error", action="stop", message=f"Apply command failed: {str(e)}"
694
+ )
695
+ )
696
+
697
+
698
+ async def handle_logs_command(command: Dict[str, Any]) -> None:
699
+ """Handle 'logs' command to view or stream logs."""
700
+ try:
701
+ level = command.get("level", "INFO")
702
+ follow = command.get("follow", False)
703
+ lines = command.get("lines", 50)
704
+
705
+ log_dir = Path.home() / ".voria"
706
+ log_file = log_dir / "voria.log"
707
+
708
+ if not log_file.exists():
709
+ send_response(
710
+ Response(
711
+ status="error",
712
+ action="stop",
713
+ message=f"No log file found at {log_file}",
714
+ )
715
+ )
716
+ return
717
+
718
+ if follow:
719
+ send_response(
720
+ Response(
721
+ status="success",
722
+ action="continue",
723
+ message="Streaming logs... (Ctrl+C to stop)",
724
+ data={"log_file": str(log_file)},
725
+ )
726
+ )
727
+ else:
728
+ try:
729
+ with open(log_file, "r") as f:
730
+ log_lines = f.readlines()[-lines:]
731
+
732
+ log_content = "".join(log_lines)
733
+ send_response(
734
+ Response(
735
+ status="success",
736
+ action="stop",
737
+ message=f"Last {len(log_lines)} log lines",
738
+ logs=log_content,
739
+ )
740
+ )
741
+ except Exception as e:
742
+ send_response(
743
+ Response(
744
+ status="error",
745
+ action="stop",
746
+ message=f"Failed to read logs: {str(e)}",
747
+ )
748
+ )
749
+
750
+ except Exception as e:
751
+ logger.error(f"Logs command error: {e}", exc_info=True)
752
+ send_response(
753
+ Response(
754
+ status="error", action="stop", message=f"Logs command failed: {str(e)}"
755
+ )
756
+ )
757
+
758
+
759
+ async def handle_token_command(command: Dict[str, Any]) -> None:
760
+ """Handle 'token' command for token usage info."""
761
+ try:
762
+ from voria.core.token_manager import get_token_manager
763
+
764
+ subcommand = command.get("subcommand", "info")
765
+
766
+ if subcommand == "info":
767
+ tm = get_token_manager()
768
+ summary = tm.get_usage_summary()
769
+ send_response(
770
+ Response(
771
+ status="success",
772
+ action="stop",
773
+ message="Token usage info",
774
+ data={"tokens": summary},
775
+ )
776
+ )
777
+ elif subcommand == "reset":
778
+ from voria.core.token_manager import init_token_manager, TokenBudget
779
+
780
+ tm = init_token_manager(TokenBudget())
781
+ send_response(
782
+ Response(status="success", action="stop", message="Token usage reset")
783
+ )
784
+ else:
785
+ send_response(
786
+ Response(
787
+ status="error",
788
+ action="stop",
789
+ message=f"Unknown token subcommand: {subcommand}",
790
+ )
791
+ )
792
+
793
+ except Exception as e:
794
+ logger.error(f"Token command error: {e}", exc_info=True)
795
+ send_response(
796
+ Response(
797
+ status="error", action="stop", message=f"Token command failed: {str(e)}"
798
+ )
799
+ )
800
+
801
+
802
+ def handle_test_results_callback(command: Dict[str, Any]) -> None:
803
+ """Handle test results callback from CLI."""
804
+ test_status = command.get("test_status")
805
+ test_logs = command.get("test_logs")
806
+
807
+ logger.info(f"Received test results: {test_status}")
808
+ if test_logs:
809
+ logger.debug(f"Test logs:\n{test_logs}")
810
+
811
+
812
+ async def process_command_async(line: str) -> None:
813
+ """Process a single NDJSON command line asynchronously."""
814
+ try:
815
+ command = json.loads(line.strip())
816
+ logger.debug(f"Command received: {command}")
817
+
818
+ cmd_type = command.get("command")
819
+
820
+ if cmd_type == "plan":
821
+ await handle_plan_command(command)
822
+ elif cmd_type == "issue":
823
+ await handle_issue_command(command)
824
+ elif cmd_type == "fix":
825
+ await handle_fix_command(command)
826
+ elif cmd_type == "list_issues":
827
+ await handle_list_issues_command(command)
828
+ elif cmd_type == "apply":
829
+ await handle_apply_command(command)
830
+ elif cmd_type == "logs":
831
+ await handle_logs_command(command)
832
+ elif cmd_type == "token":
833
+ await handle_token_command(command)
834
+ elif cmd_type == "config":
835
+ await handle_config_command(command)
836
+ elif cmd_type == "test_results":
837
+ handle_test_results_callback(command)
838
+ elif cmd_type == "create_pr":
839
+ await handle_create_pr_command(command)
840
+ else:
841
+ logger.error(f"Unknown command type: {cmd_type}")
842
+ send_response(
843
+ Response(
844
+ status="error",
845
+ action="stop",
846
+ message=f"Unknown command: {cmd_type}",
847
+ )
848
+ )
849
+ except json.JSONDecodeError as e:
850
+ logger.error(f"Failed to parse JSON: {e}")
851
+ send_response(
852
+ Response(status="error", action="stop", message=f"Invalid JSON: {str(e)}")
853
+ )
854
+ except Exception as e:
855
+ logger.error(f"Command processing error: {e}", exc_info=True)
856
+ send_response(
857
+ Response(
858
+ status="error", action="stop", message=f"Processing error: {str(e)}"
859
+ )
860
+ )
861
+
862
+
863
+ voria_CONFIG_DIR = Path.home() / ".voria"
864
+ voria_CONFIG_FILE = voria_CONFIG_DIR / "config.json"
865
+
866
+
867
+ def load_config() -> Dict[str, Any]:
868
+ """Load voria configuration from ~/.voria/config.json"""
869
+ if voria_CONFIG_FILE.exists():
870
+ try:
871
+ with open(voria_CONFIG_FILE, "r") as f:
872
+ return json.load(f)
873
+ except Exception as e:
874
+ logger.warning(f"Failed to load config: {e}")
875
+ return {}
876
+
877
+
878
+ def save_config(config: Dict[str, Any]) -> None:
879
+ """Save voria configuration to ~/.voria/config.json"""
880
+ try:
881
+ voria_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
882
+ with open(voria_CONFIG_FILE, "w") as f:
883
+ json.dump(config, f, indent=2)
884
+ os.chmod(voria_CONFIG_FILE, 0o600)
885
+ except Exception as e:
886
+ logger.error(f"Failed to save config: {e}")
887
+
888
+
889
+ async def handle_config_command(command: Dict[str, Any]) -> None:
890
+ """Handle 'config' command for managing configuration."""
891
+ try:
892
+ action = command.get("action", "get")
893
+ config = load_config()
894
+
895
+ if action == "get":
896
+ send_response(
897
+ Response(
898
+ status="success",
899
+ action="stop",
900
+ message="Current configuration",
901
+ data={"config": config},
902
+ )
903
+ )
904
+ return
905
+
906
+ if action == "set":
907
+ github_token = command.get("github_token")
908
+ llm_provider = command.get("llm_provider")
909
+ llm_api_key = command.get("llm_api_key")
910
+ llm_model = command.get("llm_model")
911
+ daily_budget = command.get("daily_budget")
912
+ test_framework = command.get("test_framework")
913
+
914
+ if github_token:
915
+ config["github_token"] = github_token
916
+ if llm_provider:
917
+ config["llm_provider"] = llm_provider
918
+ if llm_api_key:
919
+ config["llm_api_key"] = llm_api_key
920
+ if llm_model:
921
+ config["llm_model"] = llm_model
922
+ if daily_budget is not None:
923
+ config["daily_budget"] = daily_budget
924
+ if test_framework:
925
+ config["test_framework"] = test_framework
926
+
927
+ save_config(config)
928
+ send_response(
929
+ Response(
930
+ status="success",
931
+ action="stop",
932
+ message="Configuration saved successfully",
933
+ )
934
+ )
935
+ return
936
+
937
+ if action == "init":
938
+ from voria.core.github import print_token_guide, get_github_token
939
+
940
+ print("\n" + "=" * 60)
941
+ print("🚀 voria Setup - First Time Configuration")
942
+ print("=" * 60 + "\n")
943
+
944
+ config = {}
945
+
946
+ print("=" * 60)
947
+ print("STEP 1: LLM Provider Setup")
948
+ print("=" * 60)
949
+ print("Available providers: modal, openai, gemini, claude")
950
+ print("Note: Modal is FREE, Gemini is cheapest ($1-5/month)")
951
+ print()
952
+
953
+ provider = (
954
+ input("Select LLM provider (modal/openai/gemini/claude): ")
955
+ .strip()
956
+ .lower()
957
+ )
958
+ if provider not in ["modal", "openai", "gemini", "claude"]:
959
+ provider = "modal"
960
+
961
+ config["llm_provider"] = provider
962
+ print(f"✅ Using: {provider}\n")
963
+
964
+ if provider == "modal":
965
+ print("Modal is FREE! Get your key from: https://modal.com")
966
+ api_key = input("Enter your Modal API key: ").strip()
967
+ if not api_key:
968
+ print("❌ Modal API key is required!")
969
+ send_response(
970
+ Response(
971
+ status="error",
972
+ action="stop",
973
+ message="Modal API key is required",
974
+ )
975
+ )
976
+ return
977
+ config["llm_api_key"] = api_key
978
+ elif provider == "openai":
979
+ api_key = input("Enter OpenAI API key (sk-...): ").strip()
980
+ if not api_key:
981
+ print("❌ OpenAI API key is required!")
982
+ send_response(
983
+ Response(
984
+ status="error",
985
+ action="stop",
986
+ message="OpenAI API key is required",
987
+ )
988
+ )
989
+ return
990
+ config["llm_api_key"] = api_key
991
+ elif provider == "gemini":
992
+ api_key = input("Enter Google Gemini API key: ").strip()
993
+ if not api_key:
994
+ print("❌ Gemini API key is required!")
995
+ send_response(
996
+ Response(
997
+ status="error",
998
+ action="stop",
999
+ message="Gemini API key is required",
1000
+ )
1001
+ )
1002
+ return
1003
+ config["llm_api_key"] = api_key
1004
+ elif provider == "claude":
1005
+ api_key = input("Enter Anthropic Claude API key (sk-ant-...): ").strip()
1006
+ if not api_key:
1007
+ print("❌ Claude API key is required!")
1008
+ send_response(
1009
+ Response(
1010
+ status="error",
1011
+ action="stop",
1012
+ message="Claude API key is required",
1013
+ )
1014
+ )
1015
+ return
1016
+ config["llm_api_key"] = api_key
1017
+
1018
+ print(f"✅ API key saved\n")
1019
+
1020
+ print("=" * 60)
1021
+ print("STEP 2: GitHub Setup (Optional)")
1022
+ print("=" * 60)
1023
+
1024
+ setup_github = input("Setup GitHub token now? (y/n): ").lower().strip()
1025
+ if setup_github == "y":
1026
+ print_token_guide()
1027
+ token = input("\nEnter GitHub Personal Access Token: ").strip()
1028
+ if token:
1029
+ config["github_token"] = token
1030
+ print("✅ GitHub token saved\n")
1031
+ else:
1032
+ print(
1033
+ "Skipped. You can add it later with: voria config --github YOUR_TOKEN\n"
1034
+ )
1035
+
1036
+ print("=" * 60)
1037
+ print("STEP 3: Budget & Testing (Optional)")
1038
+ print("=" * 60)
1039
+
1040
+ budget_input = input("Daily budget in USD (default: 10): ").strip()
1041
+ if budget_input:
1042
+ try:
1043
+ config["daily_budget"] = float(budget_input)
1044
+ except:
1045
+ config["daily_budget"] = 10.0
1046
+ else:
1047
+ config["daily_budget"] = 10.0
1048
+
1049
+ print(f"✅ Daily budget: ${config['daily_budget']}\n")
1050
+
1051
+ print("=" * 60)
1052
+ print("STEP 4: Test Framework")
1053
+ print("=" * 60)
1054
+ print("Detected: pytest, jest, cargo test, etc.")
1055
+ framework = input(
1056
+ "Enter test framework (or press Enter for auto-detect): "
1057
+ ).strip()
1058
+ if framework:
1059
+ config["test_framework"] = framework
1060
+
1061
+ save_config(config)
1062
+
1063
+ print("\n" + "=" * 60)
1064
+ print("✅ SETUP COMPLETE!")
1065
+ print("=" * 60)
1066
+ print("\nNext steps:")
1067
+ print(" - voria issue 42 # Fix a GitHub issue")
1068
+ print(" - voria plan 'Add error handling' # Plan a fix")
1069
+ print(" - voria logs # View activity")
1070
+ print("\nTo update config later: voria config")
1071
+ print("=" * 60 + "\n")
1072
+
1073
+ send_response(
1074
+ Response(
1075
+ status="success",
1076
+ action="stop",
1077
+ message="voria initialized successfully!",
1078
+ data={"config": config},
1079
+ )
1080
+ )
1081
+ return
1082
+
1083
+ if action == "github":
1084
+ token = command.get("token")
1085
+ if token:
1086
+ config["github_token"] = token
1087
+ save_config(config)
1088
+ send_response(
1089
+ Response(
1090
+ status="success",
1091
+ action="stop",
1092
+ message="GitHub token saved! You can now use voria issue without entering token.",
1093
+ )
1094
+ )
1095
+ else:
1096
+ print_token_guide()
1097
+ token = input("Enter GitHub Personal Access Token: ").strip()
1098
+ if token:
1099
+ config["github_token"] = token
1100
+ save_config(config)
1101
+ send_response(
1102
+ Response(
1103
+ status="success",
1104
+ action="stop",
1105
+ message="GitHub token saved!",
1106
+ )
1107
+ )
1108
+ else:
1109
+ send_response(
1110
+ Response(
1111
+ status="error", action="stop", message="No token provided"
1112
+ )
1113
+ )
1114
+ return
1115
+
1116
+ send_response(
1117
+ Response(
1118
+ status="error",
1119
+ action="stop",
1120
+ message=f"Unknown config action: {action}",
1121
+ )
1122
+ )
1123
+
1124
+ except Exception as e:
1125
+ logger.error(f"Config command error: {e}", exc_info=True)
1126
+ send_response(
1127
+ Response(
1128
+ status="error",
1129
+ action="stop",
1130
+ message=f"Config command failed: {str(e)}",
1131
+ )
1132
+ )
1133
+
1134
+
1135
+ def main() -> None:
1136
+ """Main engine loop - read and process NDJSON from stdin."""
1137
+ logger.info("voria Python Engine started")
1138
+ logger.info("Ready to receive commands via NDJSON on stdin")
1139
+
1140
+ try:
1141
+ # Create a single event loop for the entire session
1142
+ loop = asyncio.get_event_loop()
1143
+ except RuntimeError:
1144
+ loop = asyncio.new_event_loop()
1145
+ asyncio.set_event_loop(loop)
1146
+
1147
+ try:
1148
+ while True:
1149
+ try:
1150
+ line = sys.stdin.readline()
1151
+
1152
+ # EOF or empty line
1153
+ if not line:
1154
+ logger.info("Received EOF, shutting down")
1155
+ break
1156
+
1157
+ # Process the command asynchronously
1158
+ loop.run_until_complete(process_command_async(line))
1159
+ except EOFError:
1160
+ logger.info("EOF received")
1161
+ break
1162
+ except Exception as e:
1163
+ logger.error(f"Error processing command: {e}", exc_info=True)
1164
+ send_response(
1165
+ Response(
1166
+ status="error",
1167
+ action="stop",
1168
+ message=f"Processing error: {str(e)}",
1169
+ )
1170
+ )
1171
+
1172
+ except KeyboardInterrupt:
1173
+ logger.info("Received interrupt signal")
1174
+ except Exception as e:
1175
+ logger.error(f"Unexpected error in main loop: {e}", exc_info=True)
1176
+ finally:
1177
+ try:
1178
+ loop.close()
1179
+ except:
1180
+ pass
1181
+ logger.info("voria Python Engine shutting down")
1182
+
1183
+
1184
+ if __name__ == "__main__":
1185
+ main()