pdd-cli 0.0.90__py3-none-any.whl → 0.0.121__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.
- pdd/__init__.py +38 -6
- pdd/agentic_bug.py +323 -0
- pdd/agentic_bug_orchestrator.py +506 -0
- pdd/agentic_change.py +231 -0
- pdd/agentic_change_orchestrator.py +537 -0
- pdd/agentic_common.py +533 -770
- pdd/agentic_crash.py +2 -1
- pdd/agentic_e2e_fix.py +319 -0
- pdd/agentic_e2e_fix_orchestrator.py +582 -0
- pdd/agentic_fix.py +118 -3
- pdd/agentic_update.py +27 -9
- pdd/agentic_verify.py +3 -2
- pdd/architecture_sync.py +565 -0
- pdd/auth_service.py +210 -0
- pdd/auto_deps_main.py +63 -53
- pdd/auto_include.py +236 -3
- pdd/auto_update.py +125 -47
- pdd/bug_main.py +195 -23
- pdd/cmd_test_main.py +345 -197
- pdd/code_generator.py +4 -2
- pdd/code_generator_main.py +118 -32
- pdd/commands/__init__.py +6 -0
- pdd/commands/analysis.py +113 -48
- pdd/commands/auth.py +309 -0
- pdd/commands/connect.py +358 -0
- pdd/commands/fix.py +155 -114
- pdd/commands/generate.py +5 -0
- pdd/commands/maintenance.py +3 -2
- pdd/commands/misc.py +8 -0
- pdd/commands/modify.py +225 -163
- pdd/commands/sessions.py +284 -0
- pdd/commands/utility.py +12 -7
- pdd/construct_paths.py +334 -32
- pdd/context_generator_main.py +167 -170
- pdd/continue_generation.py +6 -3
- pdd/core/__init__.py +33 -0
- pdd/core/cli.py +44 -7
- pdd/core/cloud.py +237 -0
- pdd/core/dump.py +68 -20
- pdd/core/errors.py +4 -0
- pdd/core/remote_session.py +61 -0
- pdd/crash_main.py +219 -23
- pdd/data/llm_model.csv +4 -4
- pdd/docs/prompting_guide.md +864 -0
- pdd/docs/whitepaper_with_benchmarks/data_and_functions/benchmark_analysis.py +495 -0
- pdd/docs/whitepaper_with_benchmarks/data_and_functions/creation_compare.py +528 -0
- pdd/fix_code_loop.py +208 -34
- pdd/fix_code_module_errors.py +6 -2
- pdd/fix_error_loop.py +291 -38
- pdd/fix_main.py +208 -6
- pdd/fix_verification_errors_loop.py +235 -26
- pdd/fix_verification_main.py +269 -83
- pdd/frontend/dist/assets/index-B5DZHykP.css +1 -0
- pdd/frontend/dist/assets/index-CUWd8al1.js +450 -0
- pdd/frontend/dist/index.html +376 -0
- pdd/frontend/dist/logo.svg +33 -0
- pdd/generate_output_paths.py +46 -5
- pdd/generate_test.py +212 -151
- pdd/get_comment.py +19 -44
- pdd/get_extension.py +8 -9
- pdd/get_jwt_token.py +309 -20
- pdd/get_language.py +8 -7
- pdd/get_run_command.py +7 -5
- pdd/insert_includes.py +2 -1
- pdd/llm_invoke.py +531 -97
- pdd/load_prompt_template.py +15 -34
- pdd/operation_log.py +342 -0
- pdd/path_resolution.py +140 -0
- pdd/postprocess.py +122 -97
- pdd/preprocess.py +68 -12
- pdd/preprocess_main.py +33 -1
- pdd/prompts/agentic_bug_step10_pr_LLM.prompt +182 -0
- pdd/prompts/agentic_bug_step1_duplicate_LLM.prompt +73 -0
- pdd/prompts/agentic_bug_step2_docs_LLM.prompt +129 -0
- pdd/prompts/agentic_bug_step3_triage_LLM.prompt +95 -0
- pdd/prompts/agentic_bug_step4_reproduce_LLM.prompt +97 -0
- pdd/prompts/agentic_bug_step5_root_cause_LLM.prompt +123 -0
- pdd/prompts/agentic_bug_step6_test_plan_LLM.prompt +107 -0
- pdd/prompts/agentic_bug_step7_generate_LLM.prompt +172 -0
- pdd/prompts/agentic_bug_step8_verify_LLM.prompt +119 -0
- pdd/prompts/agentic_bug_step9_e2e_test_LLM.prompt +289 -0
- pdd/prompts/agentic_change_step10_identify_issues_LLM.prompt +1006 -0
- pdd/prompts/agentic_change_step11_fix_issues_LLM.prompt +984 -0
- pdd/prompts/agentic_change_step12_create_pr_LLM.prompt +140 -0
- pdd/prompts/agentic_change_step1_duplicate_LLM.prompt +73 -0
- pdd/prompts/agentic_change_step2_docs_LLM.prompt +101 -0
- pdd/prompts/agentic_change_step3_research_LLM.prompt +126 -0
- pdd/prompts/agentic_change_step4_clarify_LLM.prompt +164 -0
- pdd/prompts/agentic_change_step5_docs_change_LLM.prompt +981 -0
- pdd/prompts/agentic_change_step6_devunits_LLM.prompt +1005 -0
- pdd/prompts/agentic_change_step7_architecture_LLM.prompt +1044 -0
- pdd/prompts/agentic_change_step8_analyze_LLM.prompt +1027 -0
- pdd/prompts/agentic_change_step9_implement_LLM.prompt +1077 -0
- pdd/prompts/agentic_e2e_fix_step1_unit_tests_LLM.prompt +90 -0
- pdd/prompts/agentic_e2e_fix_step2_e2e_tests_LLM.prompt +91 -0
- pdd/prompts/agentic_e2e_fix_step3_root_cause_LLM.prompt +89 -0
- pdd/prompts/agentic_e2e_fix_step4_fix_e2e_tests_LLM.prompt +96 -0
- pdd/prompts/agentic_e2e_fix_step5_identify_devunits_LLM.prompt +91 -0
- pdd/prompts/agentic_e2e_fix_step6_create_unit_tests_LLM.prompt +106 -0
- pdd/prompts/agentic_e2e_fix_step7_verify_tests_LLM.prompt +116 -0
- pdd/prompts/agentic_e2e_fix_step8_run_pdd_fix_LLM.prompt +120 -0
- pdd/prompts/agentic_e2e_fix_step9_verify_all_LLM.prompt +146 -0
- pdd/prompts/agentic_fix_primary_LLM.prompt +2 -2
- pdd/prompts/agentic_update_LLM.prompt +192 -338
- pdd/prompts/auto_include_LLM.prompt +22 -0
- pdd/prompts/change_LLM.prompt +3093 -1
- pdd/prompts/detect_change_LLM.prompt +571 -14
- pdd/prompts/fix_code_module_errors_LLM.prompt +8 -0
- pdd/prompts/fix_errors_from_unit_tests_LLM.prompt +1 -0
- pdd/prompts/generate_test_LLM.prompt +19 -1
- pdd/prompts/generate_test_from_example_LLM.prompt +366 -0
- pdd/prompts/insert_includes_LLM.prompt +262 -252
- pdd/prompts/prompt_code_diff_LLM.prompt +123 -0
- pdd/prompts/prompt_diff_LLM.prompt +82 -0
- pdd/remote_session.py +876 -0
- pdd/server/__init__.py +52 -0
- pdd/server/app.py +335 -0
- pdd/server/click_executor.py +587 -0
- pdd/server/executor.py +338 -0
- pdd/server/jobs.py +661 -0
- pdd/server/models.py +241 -0
- pdd/server/routes/__init__.py +31 -0
- pdd/server/routes/architecture.py +451 -0
- pdd/server/routes/auth.py +364 -0
- pdd/server/routes/commands.py +929 -0
- pdd/server/routes/config.py +42 -0
- pdd/server/routes/files.py +603 -0
- pdd/server/routes/prompts.py +1347 -0
- pdd/server/routes/websocket.py +473 -0
- pdd/server/security.py +243 -0
- pdd/server/terminal_spawner.py +217 -0
- pdd/server/token_counter.py +222 -0
- pdd/summarize_directory.py +236 -237
- pdd/sync_animation.py +8 -4
- pdd/sync_determine_operation.py +329 -47
- pdd/sync_main.py +272 -28
- pdd/sync_orchestration.py +289 -211
- pdd/sync_order.py +304 -0
- pdd/template_expander.py +161 -0
- pdd/templates/architecture/architecture_json.prompt +41 -46
- pdd/trace.py +1 -1
- pdd/track_cost.py +0 -13
- pdd/unfinished_prompt.py +2 -1
- pdd/update_main.py +68 -26
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/METADATA +15 -10
- pdd_cli-0.0.121.dist-info/RECORD +229 -0
- pdd_cli-0.0.90.dist-info/RECORD +0 -153
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/WHEEL +0 -0
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/entry_points.txt +0 -0
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/licenses/LICENSE +0 -0
- {pdd_cli-0.0.90.dist-info → pdd_cli-0.0.121.dist-info}/top_level.txt +0 -0
pdd/server/security.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Callable, List, Optional, Union
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI, HTTPException, Request, status, Depends
|
|
9
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
10
|
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
11
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
# Initialize Rich console for logging
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
# Default blacklist patterns
|
|
18
|
+
DEFAULT_BLACKLIST = [
|
|
19
|
+
".git", ".git/**",
|
|
20
|
+
".env", ".env.*",
|
|
21
|
+
"node_modules", "node_modules/**",
|
|
22
|
+
"__pycache__", "*.pyc",
|
|
23
|
+
".DS_Store",
|
|
24
|
+
"credentials*", "*secret*", "*.key", "*.pem",
|
|
25
|
+
"id_rsa*",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
class SecurityError(Exception):
|
|
29
|
+
"""
|
|
30
|
+
Custom exception for security violations.
|
|
31
|
+
"""
|
|
32
|
+
def __init__(self, code: str, message: str):
|
|
33
|
+
self.code = code
|
|
34
|
+
self.message = message
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PathValidator:
|
|
39
|
+
"""
|
|
40
|
+
Validates filesystem paths to prevent directory traversal and access to sensitive files.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, project_root: Path, blacklist_patterns: Optional[List[str]] = None):
|
|
44
|
+
"""
|
|
45
|
+
Initialize the validator.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
project_root: The absolute root directory of the project.
|
|
49
|
+
blacklist_patterns: Optional list of glob patterns to block.
|
|
50
|
+
If None, uses DEFAULT_BLACKLIST.
|
|
51
|
+
"""
|
|
52
|
+
self.project_root = project_root.resolve()
|
|
53
|
+
self.blacklist = blacklist_patterns if blacklist_patterns is not None else DEFAULT_BLACKLIST
|
|
54
|
+
|
|
55
|
+
def validate(self, path: Union[str, Path]) -> Path:
|
|
56
|
+
"""
|
|
57
|
+
Resolve and validate that a path is safe to access.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
path: The relative or absolute path to validate.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Path: The resolved, absolute path.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
SecurityError: If the path is invalid, blocked, or traverses outside root.
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
# 1. Construct the full candidate path
|
|
70
|
+
path_obj = Path(path)
|
|
71
|
+
|
|
72
|
+
if path_obj.is_absolute():
|
|
73
|
+
# If absolute, we must ensure it starts with project_root before resolving
|
|
74
|
+
# to catch simple string mismatches, but the real check is relative_to below.
|
|
75
|
+
candidate_path = path_obj
|
|
76
|
+
else:
|
|
77
|
+
# If relative, join with project root
|
|
78
|
+
candidate_path = self.project_root / path_obj
|
|
79
|
+
|
|
80
|
+
# 2. Resolve symlinks and '..' components
|
|
81
|
+
# strict=False allows validating paths for files that don't exist yet (e.g. for writing)
|
|
82
|
+
resolved_path = candidate_path.resolve()
|
|
83
|
+
|
|
84
|
+
# 3. Check for Directory Traversal
|
|
85
|
+
# This raises ValueError if resolved_path is not inside project_root
|
|
86
|
+
try:
|
|
87
|
+
relative_path = resolved_path.relative_to(self.project_root)
|
|
88
|
+
except ValueError:
|
|
89
|
+
console.print(f"[bold red]Security Alert:[/bold red] Path traversal attempt: {path}")
|
|
90
|
+
raise SecurityError(
|
|
91
|
+
code="PATH_TRAVERSAL",
|
|
92
|
+
message="Access denied: Path is outside the project root."
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# 4. Check Blacklist
|
|
96
|
+
# We check the relative path parts against patterns
|
|
97
|
+
path_str = str(relative_path)
|
|
98
|
+
parts = relative_path.parts
|
|
99
|
+
|
|
100
|
+
for pattern in self.blacklist:
|
|
101
|
+
# Check the full relative path string
|
|
102
|
+
if fnmatch.fnmatch(path_str, pattern):
|
|
103
|
+
self._raise_blacklist_error(path_str, pattern)
|
|
104
|
+
|
|
105
|
+
# Check individual path components (e.g., blocking 'node_modules' anywhere in tree)
|
|
106
|
+
# This handles cases like 'src/node_modules/foo' matching 'node_modules'
|
|
107
|
+
for part in parts:
|
|
108
|
+
if fnmatch.fnmatch(part, pattern):
|
|
109
|
+
self._raise_blacklist_error(path_str, pattern)
|
|
110
|
+
|
|
111
|
+
return resolved_path
|
|
112
|
+
|
|
113
|
+
except SecurityError:
|
|
114
|
+
raise
|
|
115
|
+
except Exception as e:
|
|
116
|
+
console.print(f"[bold red]Error:[/bold red] Invalid path processing: {e}")
|
|
117
|
+
raise SecurityError(code="INVALID_PATH", message=f"Invalid path: {str(e)}")
|
|
118
|
+
|
|
119
|
+
def _raise_blacklist_error(self, path: str, pattern: str):
|
|
120
|
+
console.print(f"[bold red]Security Alert:[/bold red] Blocked access to blacklisted resource: {path} (matched {pattern})")
|
|
121
|
+
raise SecurityError(
|
|
122
|
+
code="BLACKLISTED_PATH",
|
|
123
|
+
message="Access denied: Resource is blacklisted."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def configure_cors(app: FastAPI, allowed_origins: Optional[List[str]] = None) -> None:
|
|
128
|
+
"""
|
|
129
|
+
Configure CORS middleware for the FastAPI application.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
app: The FastAPI application instance.
|
|
133
|
+
allowed_origins: List of allowed origins. Defaults to standard local dev ports.
|
|
134
|
+
"""
|
|
135
|
+
origins = allowed_origins or [
|
|
136
|
+
"http://localhost:3000",
|
|
137
|
+
"http://127.0.0.1:3000",
|
|
138
|
+
"http://localhost:5173",
|
|
139
|
+
"http://127.0.0.1:5173",
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
app.add_middleware(
|
|
143
|
+
CORSMiddleware,
|
|
144
|
+
allow_origins=origins,
|
|
145
|
+
allow_credentials=True,
|
|
146
|
+
allow_methods=["*"],
|
|
147
|
+
allow_headers=["*"],
|
|
148
|
+
expose_headers=["X-Job-Id", "X-Total-Chunks"],
|
|
149
|
+
)
|
|
150
|
+
console.print(f"[green]CORS configured for origins:[/green] {origins}")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def create_token_dependency(token: Optional[str]) -> Callable:
|
|
154
|
+
"""
|
|
155
|
+
Creates a FastAPI dependency for Bearer token authentication.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
token: The expected secret token. If None, authentication is disabled.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
A dependency function to be used with Depends().
|
|
162
|
+
"""
|
|
163
|
+
security = HTTPBearer(auto_error=False)
|
|
164
|
+
|
|
165
|
+
async def verify_token(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)):
|
|
166
|
+
if token is None:
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
if not credentials:
|
|
170
|
+
raise HTTPException(
|
|
171
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
172
|
+
detail="Missing authentication credentials",
|
|
173
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
if credentials.credentials != token:
|
|
177
|
+
console.print("[bold red]Auth Failed:[/bold red] Invalid token provided.")
|
|
178
|
+
raise HTTPException(
|
|
179
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
180
|
+
detail="Invalid authentication token",
|
|
181
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
return credentials
|
|
185
|
+
|
|
186
|
+
return verify_token
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class SecurityLoggingMiddleware(BaseHTTPMiddleware):
|
|
190
|
+
"""
|
|
191
|
+
Middleware to log requests and provide basic request validation/monitoring.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
# Endpoints that are polled frequently and should be quiet
|
|
195
|
+
QUIET_ENDPOINTS = [
|
|
196
|
+
"/api/v1/commands/jobs/", # Job status polling
|
|
197
|
+
"/api/v1/commands/spawned-jobs/", # Spawned job status polling
|
|
198
|
+
"/api/v1/prompts", # Prompt listing
|
|
199
|
+
"/api/v1/status", # Health checks
|
|
200
|
+
"/api/v1/auth/jwt-token", # JWT token polling
|
|
201
|
+
"/api/v1/auth/status", # Auth status
|
|
202
|
+
"/api/v1/files/", # File operations
|
|
203
|
+
"/api/v1/config/", # Config endpoints
|
|
204
|
+
"/assets/", # Static assets
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
# Also skip root and static files
|
|
208
|
+
QUIET_EXACT = ["/", "/index.html", "/favicon.ico"]
|
|
209
|
+
|
|
210
|
+
def _should_log(self, path: str) -> bool:
|
|
211
|
+
"""Check if we should log this request (skip noisy polling endpoints)."""
|
|
212
|
+
# Check exact matches first
|
|
213
|
+
if path in self.QUIET_EXACT:
|
|
214
|
+
return False
|
|
215
|
+
# Check prefix/substring matches
|
|
216
|
+
for quiet_path in self.QUIET_ENDPOINTS:
|
|
217
|
+
if quiet_path in path:
|
|
218
|
+
return False
|
|
219
|
+
return True
|
|
220
|
+
|
|
221
|
+
async def dispatch(self, request: Request, call_next):
|
|
222
|
+
start_time = time.time()
|
|
223
|
+
client_host = request.client.host if request.client else "unknown"
|
|
224
|
+
path = request.url.path
|
|
225
|
+
should_log = self._should_log(path)
|
|
226
|
+
|
|
227
|
+
# Log incoming request (skip noisy polling endpoints)
|
|
228
|
+
if should_log:
|
|
229
|
+
console.print(f"[dim]Request:[/dim] {request.method} {path} [dim]from {client_host}[/dim]")
|
|
230
|
+
|
|
231
|
+
response = await call_next(request)
|
|
232
|
+
|
|
233
|
+
process_time = (time.time() - start_time) * 1000
|
|
234
|
+
|
|
235
|
+
# Log response (skip noisy endpoints, but always log errors)
|
|
236
|
+
if should_log or response.status_code >= 400:
|
|
237
|
+
status_color = "green" if response.status_code < 400 else "red"
|
|
238
|
+
console.print(
|
|
239
|
+
f"[dim]Response:[/dim] [{status_color}]{response.status_code}[/{status_color}] "
|
|
240
|
+
f"took {process_time:.2f}ms"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
return response
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cross-platform terminal spawning utilities.
|
|
3
|
+
|
|
4
|
+
Allows spawning new terminal windows to run commands in isolation,
|
|
5
|
+
rather than running them in the same process as the server.
|
|
6
|
+
|
|
7
|
+
Each spawned terminal calls back to the server when the command completes,
|
|
8
|
+
enabling automatic progress tracking in the frontend dashboard.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Default server port for callback (must match PDD server port)
|
|
20
|
+
DEFAULT_SERVER_PORT = 9876
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TerminalSpawner:
|
|
24
|
+
"""Spawn terminal windows on macOS, Linux, and Windows."""
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
def spawn(
|
|
28
|
+
command: str,
|
|
29
|
+
working_dir: Optional[str] = None,
|
|
30
|
+
job_id: Optional[str] = None,
|
|
31
|
+
server_port: int = DEFAULT_SERVER_PORT,
|
|
32
|
+
) -> bool:
|
|
33
|
+
"""
|
|
34
|
+
Spawn a new terminal window and execute command.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
command: Shell command to execute
|
|
38
|
+
working_dir: Optional working directory for the command
|
|
39
|
+
job_id: Optional job ID for tracking - enables completion callback
|
|
40
|
+
server_port: Server port for completion callback (default: 5000)
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
True if terminal was spawned successfully
|
|
44
|
+
"""
|
|
45
|
+
if working_dir:
|
|
46
|
+
# Quote the path to handle spaces and special characters
|
|
47
|
+
command = f'cd "{working_dir}" && {command}'
|
|
48
|
+
|
|
49
|
+
platform = sys.platform
|
|
50
|
+
|
|
51
|
+
if platform == "darwin":
|
|
52
|
+
return TerminalSpawner._darwin(command, job_id, server_port)
|
|
53
|
+
elif platform == "linux":
|
|
54
|
+
return TerminalSpawner._linux(command, job_id, server_port)
|
|
55
|
+
elif platform == "win32":
|
|
56
|
+
return TerminalSpawner._windows(command, job_id, server_port)
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def _darwin(
|
|
61
|
+
command: str,
|
|
62
|
+
job_id: Optional[str] = None,
|
|
63
|
+
server_port: int = DEFAULT_SERVER_PORT,
|
|
64
|
+
) -> bool:
|
|
65
|
+
"""
|
|
66
|
+
macOS: Open Terminal.app with command.
|
|
67
|
+
|
|
68
|
+
Creates a temporary shell script and opens it with Terminal.app.
|
|
69
|
+
The script keeps the terminal open after command completes.
|
|
70
|
+
If job_id is provided, calls back to server with completion status.
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
# Create unique script path to avoid conflicts
|
|
74
|
+
script_path = Path(f"/tmp/pdd_terminal_{os.getpid()}_{id(command)}.sh")
|
|
75
|
+
|
|
76
|
+
# Build callback section if job_id provided
|
|
77
|
+
if job_id:
|
|
78
|
+
callback_section = f'''
|
|
79
|
+
# Report completion to server (must complete before exec bash)
|
|
80
|
+
EXIT_CODE=${{EXIT_CODE:-0}}
|
|
81
|
+
if [ "$EXIT_CODE" -eq 0 ]; then
|
|
82
|
+
SUCCESS_JSON="true"
|
|
83
|
+
else
|
|
84
|
+
SUCCESS_JSON="false"
|
|
85
|
+
fi
|
|
86
|
+
echo "[DEBUG] Sending callback to http://localhost:{server_port}/api/v1/commands/spawned-jobs/{job_id}/complete"
|
|
87
|
+
echo "[DEBUG] Payload: {{\\"success\\": $SUCCESS_JSON, \\"exit_code\\": $EXIT_CODE}}"
|
|
88
|
+
CURL_RESPONSE=$(curl -s -w "\\n[HTTP_STATUS:%{{http_code}}]" -X POST "http://localhost:{server_port}/api/v1/commands/spawned-jobs/{job_id}/complete" \\
|
|
89
|
+
-H "Content-Type: application/json" \\
|
|
90
|
+
-d "{{\\"success\\": $SUCCESS_JSON, \\"exit_code\\": $EXIT_CODE}}" 2>&1)
|
|
91
|
+
echo "[DEBUG] Curl response: $CURL_RESPONSE"
|
|
92
|
+
|
|
93
|
+
# Show result to user
|
|
94
|
+
echo ""
|
|
95
|
+
if [ "$EXIT_CODE" -eq 0 ]; then
|
|
96
|
+
echo -e "\\033[32m✓ Command completed successfully\\033[0m"
|
|
97
|
+
else
|
|
98
|
+
echo -e "\\033[31m✗ Command failed (exit code: $EXIT_CODE)\\033[0m"
|
|
99
|
+
fi
|
|
100
|
+
echo ""
|
|
101
|
+
'''
|
|
102
|
+
else:
|
|
103
|
+
callback_section = ""
|
|
104
|
+
|
|
105
|
+
# Script that runs command and optionally reports status
|
|
106
|
+
script_content = f"""#!/bin/bash
|
|
107
|
+
{command}
|
|
108
|
+
EXIT_CODE=$?
|
|
109
|
+
{callback_section}
|
|
110
|
+
exec bash
|
|
111
|
+
"""
|
|
112
|
+
script_path.write_text(script_content)
|
|
113
|
+
script_path.chmod(0o755)
|
|
114
|
+
|
|
115
|
+
# Open with Terminal.app
|
|
116
|
+
subprocess.Popen(["open", "-a", "Terminal", str(script_path)])
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
except Exception as e:
|
|
120
|
+
print(f"Failed to spawn terminal on macOS: {e}")
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
@staticmethod
|
|
124
|
+
def _linux(
|
|
125
|
+
command: str,
|
|
126
|
+
job_id: Optional[str] = None,
|
|
127
|
+
server_port: int = DEFAULT_SERVER_PORT,
|
|
128
|
+
) -> bool:
|
|
129
|
+
"""
|
|
130
|
+
Linux: Use gnome-terminal, xfce4-terminal, or konsole.
|
|
131
|
+
|
|
132
|
+
Tries each terminal emulator in order until one works.
|
|
133
|
+
If job_id is provided, calls back to server with completion status.
|
|
134
|
+
"""
|
|
135
|
+
try:
|
|
136
|
+
# Build callback section if job_id provided
|
|
137
|
+
if job_id:
|
|
138
|
+
callback_cmd = f'''
|
|
139
|
+
EXIT_CODE=$?
|
|
140
|
+
EXIT_CODE=${{EXIT_CODE:-0}}
|
|
141
|
+
if [ "$EXIT_CODE" -eq 0 ]; then SUCCESS_JSON="true"; else SUCCESS_JSON="false"; fi
|
|
142
|
+
echo "[DEBUG] Sending callback to http://localhost:{server_port}/api/v1/commands/spawned-jobs/{job_id}/complete"
|
|
143
|
+
CURL_RESPONSE=$(curl -s -w "\\n[HTTP_STATUS:%{{http_code}}]" -X POST "http://localhost:{server_port}/api/v1/commands/spawned-jobs/{job_id}/complete" \\
|
|
144
|
+
-H "Content-Type: application/json" \\
|
|
145
|
+
-d "{{\\"success\\": $SUCCESS_JSON, \\"exit_code\\": $EXIT_CODE}}" 2>&1)
|
|
146
|
+
echo "[DEBUG] Curl response: $CURL_RESPONSE"
|
|
147
|
+
echo ""
|
|
148
|
+
if [ "$EXIT_CODE" -eq 0 ]; then echo -e "\\033[32m✓ Command completed successfully\\033[0m"; else echo -e "\\033[31m✗ Command failed (exit code: $EXIT_CODE)\\033[0m"; fi
|
|
149
|
+
'''
|
|
150
|
+
full_cmd = f"{command}; {callback_cmd}; exec bash"
|
|
151
|
+
else:
|
|
152
|
+
full_cmd = f"{command}; exec bash"
|
|
153
|
+
|
|
154
|
+
terminals = [
|
|
155
|
+
("gnome-terminal", ["gnome-terminal", "--", "bash", "-c", full_cmd]),
|
|
156
|
+
("xfce4-terminal", ["xfce4-terminal", "-e", f"bash -c '{full_cmd}'"]),
|
|
157
|
+
("konsole", ["konsole", "-e", "bash", "-c", full_cmd]),
|
|
158
|
+
("xterm", ["xterm", "-e", "bash", "-c", full_cmd]),
|
|
159
|
+
]
|
|
160
|
+
|
|
161
|
+
for term_name, args in terminals:
|
|
162
|
+
if shutil.which(term_name):
|
|
163
|
+
subprocess.Popen(args)
|
|
164
|
+
return True
|
|
165
|
+
|
|
166
|
+
print("No supported terminal emulator found on Linux")
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
except Exception as e:
|
|
170
|
+
print(f"Failed to spawn terminal on Linux: {e}")
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def _windows(
|
|
175
|
+
command: str,
|
|
176
|
+
job_id: Optional[str] = None,
|
|
177
|
+
server_port: int = DEFAULT_SERVER_PORT,
|
|
178
|
+
) -> bool:
|
|
179
|
+
"""
|
|
180
|
+
Windows: Use Windows Terminal or PowerShell.
|
|
181
|
+
|
|
182
|
+
Tries Windows Terminal first, falls back to PowerShell.
|
|
183
|
+
If job_id is provided, calls back to server with completion status.
|
|
184
|
+
"""
|
|
185
|
+
try:
|
|
186
|
+
# Build callback section if job_id provided
|
|
187
|
+
if job_id:
|
|
188
|
+
callback_cmd = f'''
|
|
189
|
+
$exitCode = $LASTEXITCODE
|
|
190
|
+
$success = if ($exitCode -eq 0) {{ "true" }} else {{ "false" }}
|
|
191
|
+
Invoke-RestMethod -Uri "http://localhost:{server_port}/api/v1/commands/spawned-jobs/{job_id}/complete" -Method Post -ContentType "application/json" -Body ('{{"success": ' + $success + ', "exit_code": ' + $exitCode + '}}') -ErrorAction SilentlyContinue
|
|
192
|
+
Write-Host ""
|
|
193
|
+
if ($exitCode -eq 0) {{ Write-Host "✓ Command completed successfully" -ForegroundColor Green }} else {{ Write-Host "✗ Command failed (exit code: $exitCode)" -ForegroundColor Red }}
|
|
194
|
+
'''
|
|
195
|
+
full_cmd = f"{command}; {callback_cmd}"
|
|
196
|
+
else:
|
|
197
|
+
full_cmd = command
|
|
198
|
+
|
|
199
|
+
# Try Windows Terminal first (modern)
|
|
200
|
+
try:
|
|
201
|
+
subprocess.Popen([
|
|
202
|
+
"wt.exe", "new-tab",
|
|
203
|
+
"powershell", "-NoExit", "-Command", full_cmd
|
|
204
|
+
])
|
|
205
|
+
return True
|
|
206
|
+
except FileNotFoundError:
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
# Fallback to PowerShell directly
|
|
210
|
+
subprocess.Popen([
|
|
211
|
+
"powershell.exe", "-NoExit", "-Command", full_cmd
|
|
212
|
+
])
|
|
213
|
+
return True
|
|
214
|
+
|
|
215
|
+
except Exception as e:
|
|
216
|
+
print(f"Failed to spawn terminal on Windows: {e}")
|
|
217
|
+
return False
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Token counting and cost estimation utilities.
|
|
3
|
+
|
|
4
|
+
Uses tiktoken for local token estimation without API calls.
|
|
5
|
+
Loads model pricing from .pdd/llm_model.csv.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import csv
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from functools import lru_cache
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional, Dict
|
|
15
|
+
|
|
16
|
+
import tiktoken
|
|
17
|
+
|
|
18
|
+
# Default context limits by model family (tokens)
|
|
19
|
+
MODEL_CONTEXT_LIMITS = {
|
|
20
|
+
"gpt-4": 128000,
|
|
21
|
+
"gpt-5": 200000,
|
|
22
|
+
"claude-3": 200000,
|
|
23
|
+
"claude-sonnet-4": 200000,
|
|
24
|
+
"claude-opus-4": 200000,
|
|
25
|
+
"claude-haiku-4": 200000,
|
|
26
|
+
"gemini-2": 1000000,
|
|
27
|
+
"gemini-3": 1000000,
|
|
28
|
+
"default": 128000,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# Tiktoken encodings - use cl100k_base for most modern models
|
|
32
|
+
ENCODING_NAME = "cl100k_base"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class CostEstimate:
|
|
37
|
+
"""Cost estimation result."""
|
|
38
|
+
input_cost: float
|
|
39
|
+
model: str
|
|
40
|
+
tokens: int
|
|
41
|
+
cost_per_million: float
|
|
42
|
+
currency: str = "USD"
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> Dict:
|
|
45
|
+
return {
|
|
46
|
+
"input_cost": round(self.input_cost, 6),
|
|
47
|
+
"model": self.model,
|
|
48
|
+
"tokens": self.tokens,
|
|
49
|
+
"cost_per_million": self.cost_per_million,
|
|
50
|
+
"currency": self.currency,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class TokenMetrics:
|
|
56
|
+
"""Combined token metrics result."""
|
|
57
|
+
token_count: int
|
|
58
|
+
context_limit: int
|
|
59
|
+
context_usage_percent: float
|
|
60
|
+
cost_estimate: Optional[CostEstimate]
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> Dict:
|
|
63
|
+
return {
|
|
64
|
+
"token_count": self.token_count,
|
|
65
|
+
"context_limit": self.context_limit,
|
|
66
|
+
"context_usage_percent": round(self.context_usage_percent, 2),
|
|
67
|
+
"cost_estimate": self.cost_estimate.to_dict() if self.cost_estimate else None,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@lru_cache(maxsize=1)
|
|
72
|
+
def _get_encoding() -> tiktoken.Encoding:
|
|
73
|
+
"""Get tiktoken encoding (cached)."""
|
|
74
|
+
return tiktoken.get_encoding(ENCODING_NAME)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def count_tokens(text: str) -> int:
|
|
78
|
+
"""
|
|
79
|
+
Count tokens in text using tiktoken.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
text: The text to count tokens for
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Token count
|
|
86
|
+
"""
|
|
87
|
+
if not text:
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
encoding = _get_encoding()
|
|
91
|
+
return len(encoding.encode(text))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_context_limit(model: str) -> int:
|
|
95
|
+
"""
|
|
96
|
+
Get the context limit for a model.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
model: Model name
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Context limit in tokens
|
|
103
|
+
"""
|
|
104
|
+
model_lower = model.lower()
|
|
105
|
+
|
|
106
|
+
for prefix, limit in MODEL_CONTEXT_LIMITS.items():
|
|
107
|
+
if prefix in model_lower:
|
|
108
|
+
return limit
|
|
109
|
+
|
|
110
|
+
return MODEL_CONTEXT_LIMITS["default"]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@lru_cache(maxsize=1)
|
|
114
|
+
def _load_model_pricing(csv_path: str) -> Dict[str, float]:
|
|
115
|
+
"""Load model pricing from CSV (cached)."""
|
|
116
|
+
pricing = {}
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
with open(csv_path, 'r') as f:
|
|
120
|
+
reader = csv.DictReader(f)
|
|
121
|
+
for row in reader:
|
|
122
|
+
model = row.get('model', '')
|
|
123
|
+
input_cost = row.get('input', '0')
|
|
124
|
+
try:
|
|
125
|
+
pricing[model] = float(input_cost)
|
|
126
|
+
except ValueError:
|
|
127
|
+
continue
|
|
128
|
+
except (FileNotFoundError, PermissionError):
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
return pricing
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def estimate_cost(
|
|
135
|
+
token_count: int,
|
|
136
|
+
model: str,
|
|
137
|
+
pricing_csv: Optional[Path] = None
|
|
138
|
+
) -> Optional[CostEstimate]:
|
|
139
|
+
"""
|
|
140
|
+
Estimate the input cost for a given token count.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
token_count: Number of input tokens
|
|
144
|
+
model: Model name
|
|
145
|
+
pricing_csv: Path to llm_model.csv (optional)
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
CostEstimate or None if pricing not found
|
|
149
|
+
"""
|
|
150
|
+
if pricing_csv is None or not pricing_csv.exists():
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
pricing = _load_model_pricing(str(pricing_csv))
|
|
154
|
+
|
|
155
|
+
if not pricing:
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
# Find matching model
|
|
159
|
+
cost_per_million = None
|
|
160
|
+
matched_model = model
|
|
161
|
+
|
|
162
|
+
# Try exact match first
|
|
163
|
+
if model in pricing:
|
|
164
|
+
cost_per_million = pricing[model]
|
|
165
|
+
else:
|
|
166
|
+
# Try partial match
|
|
167
|
+
model_lower = model.lower()
|
|
168
|
+
for csv_model, cost in pricing.items():
|
|
169
|
+
if model_lower in csv_model.lower() or csv_model.lower() in model_lower:
|
|
170
|
+
cost_per_million = cost
|
|
171
|
+
matched_model = csv_model
|
|
172
|
+
break
|
|
173
|
+
|
|
174
|
+
if cost_per_million is None:
|
|
175
|
+
# Use a default model for estimation
|
|
176
|
+
for default_model in ['claude-sonnet-4-20250514', 'gpt-4o', 'claude-3-5-sonnet-latest']:
|
|
177
|
+
if default_model in pricing:
|
|
178
|
+
cost_per_million = pricing[default_model]
|
|
179
|
+
matched_model = default_model
|
|
180
|
+
break
|
|
181
|
+
|
|
182
|
+
if cost_per_million is None:
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
# Calculate cost (pricing is per million tokens)
|
|
186
|
+
input_cost = (token_count / 1_000_000) * cost_per_million
|
|
187
|
+
|
|
188
|
+
return CostEstimate(
|
|
189
|
+
input_cost=input_cost,
|
|
190
|
+
model=matched_model,
|
|
191
|
+
tokens=token_count,
|
|
192
|
+
cost_per_million=cost_per_million,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def get_token_metrics(
|
|
197
|
+
text: str,
|
|
198
|
+
model: str = "claude-sonnet-4-20250514",
|
|
199
|
+
pricing_csv: Optional[Path] = None
|
|
200
|
+
) -> TokenMetrics:
|
|
201
|
+
"""
|
|
202
|
+
Get comprehensive token metrics for text.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
text: The text to analyze
|
|
206
|
+
model: Model name
|
|
207
|
+
pricing_csv: Path to pricing CSV
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
TokenMetrics with count, context usage, and cost
|
|
211
|
+
"""
|
|
212
|
+
token_count = count_tokens(text)
|
|
213
|
+
context_limit = get_context_limit(model)
|
|
214
|
+
context_usage = (token_count / context_limit) * 100 if context_limit > 0 else 0
|
|
215
|
+
cost = estimate_cost(token_count, model, pricing_csv)
|
|
216
|
+
|
|
217
|
+
return TokenMetrics(
|
|
218
|
+
token_count=token_count,
|
|
219
|
+
context_limit=context_limit,
|
|
220
|
+
context_usage_percent=context_usage,
|
|
221
|
+
cost_estimate=cost,
|
|
222
|
+
)
|