scry-run 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.
- scry_run/__init__.py +102 -0
- scry_run/backends/__init__.py +6 -0
- scry_run/backends/base.py +65 -0
- scry_run/backends/claude.py +404 -0
- scry_run/backends/frozen.py +85 -0
- scry_run/backends/registry.py +72 -0
- scry_run/cache.py +441 -0
- scry_run/cli/__init__.py +137 -0
- scry_run/cli/apps.py +396 -0
- scry_run/cli/cache.py +342 -0
- scry_run/cli/config_cmd.py +84 -0
- scry_run/cli/env.py +27 -0
- scry_run/cli/init.py +375 -0
- scry_run/cli/run.py +71 -0
- scry_run/config.py +141 -0
- scry_run/console.py +52 -0
- scry_run/context.py +298 -0
- scry_run/generator.py +698 -0
- scry_run/home.py +60 -0
- scry_run/logging.py +171 -0
- scry_run/meta.py +1852 -0
- scry_run/packages.py +175 -0
- scry_run-0.1.0.dist-info/METADATA +282 -0
- scry_run-0.1.0.dist-info/RECORD +26 -0
- scry_run-0.1.0.dist-info/WHEEL +4 -0
- scry_run-0.1.0.dist-info/entry_points.txt +2 -0
scry_run/__init__.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""scry-run: LLM-powered dynamic code generation via metaclasses."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from scry_run.meta import ScryClass, ScryMeta
|
|
6
|
+
from scry_run.cache import ScryCache
|
|
7
|
+
from scry_run.home import get_home, get_app_dir, ensure_home_exists
|
|
8
|
+
from scry_run.config import Config, load_config
|
|
9
|
+
from scry_run.generator import (
|
|
10
|
+
CodeGenerator,
|
|
11
|
+
ScryRunError,
|
|
12
|
+
APIKeyError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
QuotaExceededError,
|
|
15
|
+
ModelNotFoundError,
|
|
16
|
+
ContentBlockedError,
|
|
17
|
+
NetworkError,
|
|
18
|
+
CodeGenerationError,
|
|
19
|
+
CodeValidationError,
|
|
20
|
+
)
|
|
21
|
+
from scry_run.context import ContextBuilder
|
|
22
|
+
from scry_run.backends.frozen import FrozenAppError
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def scry_create(name: str, description: str = "") -> type:
|
|
26
|
+
"""Create a new ScryClass subclass dynamically.
|
|
27
|
+
|
|
28
|
+
Use this to create multiple classes without manually defining them.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
name: Class name (e.g., "TodoItem", "Database")
|
|
32
|
+
description: Docstring describing what this class does
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
A new class inheriting from ScryClass
|
|
36
|
+
|
|
37
|
+
Example:
|
|
38
|
+
>>> TodoItem = scry_create("TodoItem", "A single todo item with title and status")
|
|
39
|
+
>>> item = TodoItem("Buy groceries")
|
|
40
|
+
>>> item.mark_done() # auto-generated!
|
|
41
|
+
"""
|
|
42
|
+
doc = description or f"Auto-generated {name} class"
|
|
43
|
+
return type(name, (ScryClass,), {"__doc__": doc})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_generated(obj: Any) -> bool:
|
|
47
|
+
"""Check if an object (function, method, property, class) was generated by scry-run.
|
|
48
|
+
|
|
49
|
+
This function reliably checks for internal metadata attached to generated code.
|
|
50
|
+
It handles unwrapping of properties, classmethods, and bound methods.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
obj: The object to check
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
True if the object was generated by LLM, False otherwise.
|
|
57
|
+
"""
|
|
58
|
+
marker = "_llm_is_generated"
|
|
59
|
+
|
|
60
|
+
# Handle descriptors (property, classmethod, staticmethod)
|
|
61
|
+
if isinstance(obj, property) and obj.fget:
|
|
62
|
+
return getattr(obj.fget, marker, False)
|
|
63
|
+
|
|
64
|
+
if isinstance(obj, (classmethod, staticmethod)):
|
|
65
|
+
return getattr(obj.__func__, marker, False)
|
|
66
|
+
|
|
67
|
+
# Handle bound methods (e.g. instance.method)
|
|
68
|
+
if hasattr(obj, "__func__"):
|
|
69
|
+
return getattr(obj.__func__, marker, False)
|
|
70
|
+
|
|
71
|
+
# Handle plain functions/classes
|
|
72
|
+
return getattr(obj, marker, False)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
__version__ = "0.1.0"
|
|
76
|
+
__all__ = [
|
|
77
|
+
"ScryClass",
|
|
78
|
+
"ScryMeta",
|
|
79
|
+
"ScryCache",
|
|
80
|
+
"CodeGenerator",
|
|
81
|
+
"ContextBuilder",
|
|
82
|
+
"scry_create",
|
|
83
|
+
"is_generated",
|
|
84
|
+
# Home directory
|
|
85
|
+
"get_home",
|
|
86
|
+
"get_app_dir",
|
|
87
|
+
"ensure_home_exists",
|
|
88
|
+
# Config
|
|
89
|
+
"Config",
|
|
90
|
+
"load_config",
|
|
91
|
+
# Exceptions
|
|
92
|
+
"ScryRunError",
|
|
93
|
+
"APIKeyError",
|
|
94
|
+
"RateLimitError",
|
|
95
|
+
"QuotaExceededError",
|
|
96
|
+
"ModelNotFoundError",
|
|
97
|
+
"ContentBlockedError",
|
|
98
|
+
"NetworkError",
|
|
99
|
+
"CodeGenerationError",
|
|
100
|
+
"CodeValidationError",
|
|
101
|
+
"FrozenAppError",
|
|
102
|
+
]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Abstract base class for generator backends."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class GeneratedCode:
|
|
10
|
+
"""Result of structured code generation."""
|
|
11
|
+
|
|
12
|
+
code: str
|
|
13
|
+
code_type: str # method, property, classmethod, staticmethod
|
|
14
|
+
docstring: str
|
|
15
|
+
dependencies: list[str]
|
|
16
|
+
packages: list[str] = None # PyPI packages to install
|
|
17
|
+
|
|
18
|
+
def __post_init__(self):
|
|
19
|
+
if self.packages is None:
|
|
20
|
+
self.packages = []
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class GeneratorBackend(ABC):
|
|
24
|
+
"""Abstract base class for LLM generator backends.
|
|
25
|
+
|
|
26
|
+
Backends handle the actual communication with the LLM service,
|
|
27
|
+
whether via CLI subprocess, direct API, or other methods.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
name: str = "base"
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def generate_text(self, prompt: str) -> str:
|
|
34
|
+
"""Generate freeform text from a prompt.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
prompt: The prompt to send to the LLM
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Generated text response
|
|
41
|
+
"""
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
@abstractmethod
|
|
45
|
+
def generate_code(self, prompt: str) -> GeneratedCode:
|
|
46
|
+
"""Generate structured code from a prompt.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
prompt: The prompt including context and requirements
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
GeneratedCode with code, type, docstring, and dependencies
|
|
53
|
+
"""
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def is_available(cls) -> bool:
|
|
58
|
+
"""Check if this backend is available for use.
|
|
59
|
+
|
|
60
|
+
Override to check for required credentials, CLI tools, etc.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
True if backend can be used
|
|
64
|
+
"""
|
|
65
|
+
return True
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""Claude Code backend - uses persistent ClaudeSDKClient session.
|
|
2
|
+
|
|
3
|
+
This backend maintains a single Claude process across multiple code generation
|
|
4
|
+
requests. Context is provided only on session startup; subsequent generations
|
|
5
|
+
send only the minimal "frame" (class/method to generate).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import atexit
|
|
12
|
+
import json
|
|
13
|
+
import shutil
|
|
14
|
+
import threading
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
from scry_run.backends.base import GeneratorBackend, GeneratedCode
|
|
18
|
+
from scry_run.backends.registry import register_backend
|
|
19
|
+
from scry_run.console import err_console
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ClaudeSessionManager:
|
|
23
|
+
"""Manages a persistent ClaudeSDKClient session.
|
|
24
|
+
|
|
25
|
+
This singleton maintains a Claude session across multiple requests.
|
|
26
|
+
Context is provided once at startup, then each generation request
|
|
27
|
+
sends only the minimal "frame" information.
|
|
28
|
+
|
|
29
|
+
Session resilience:
|
|
30
|
+
- If session dies, it will be restarted on next request
|
|
31
|
+
- Full context is re-provided on restart
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
_instance: Optional["ClaudeSessionManager"] = None
|
|
35
|
+
_lock = threading.Lock()
|
|
36
|
+
|
|
37
|
+
def __new__(cls) -> "ClaudeSessionManager":
|
|
38
|
+
if cls._instance is None:
|
|
39
|
+
with cls._lock:
|
|
40
|
+
if cls._instance is None:
|
|
41
|
+
cls._instance = super().__new__(cls)
|
|
42
|
+
cls._instance._initialized = False
|
|
43
|
+
return cls._instance
|
|
44
|
+
|
|
45
|
+
def __init__(self):
|
|
46
|
+
if self._initialized:
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
self._initialized = True
|
|
50
|
+
self._client: Any = None
|
|
51
|
+
self._connected = False
|
|
52
|
+
self._context: Optional[str] = None # Stored for session restart
|
|
53
|
+
self._model: Optional[str] = None
|
|
54
|
+
|
|
55
|
+
# Background event loop for async SDK
|
|
56
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
57
|
+
self._thread: Optional[threading.Thread] = None
|
|
58
|
+
self._thread_ready = threading.Event()
|
|
59
|
+
|
|
60
|
+
# Register shutdown handler
|
|
61
|
+
atexit.register(self._shutdown_sync)
|
|
62
|
+
|
|
63
|
+
def _ensure_loop_running(self) -> None:
|
|
64
|
+
"""Ensure the background event loop is running."""
|
|
65
|
+
if self._loop is not None and self._thread is not None and self._thread.is_alive():
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
def run_loop():
|
|
69
|
+
self._loop = asyncio.new_event_loop()
|
|
70
|
+
asyncio.set_event_loop(self._loop)
|
|
71
|
+
self._thread_ready.set()
|
|
72
|
+
self._loop.run_forever()
|
|
73
|
+
|
|
74
|
+
self._thread = threading.Thread(target=run_loop, daemon=True, name="claude-sdk-loop")
|
|
75
|
+
self._thread.start()
|
|
76
|
+
self._thread_ready.wait(timeout=5.0)
|
|
77
|
+
|
|
78
|
+
if self._loop is None:
|
|
79
|
+
raise RuntimeError("Failed to start background event loop")
|
|
80
|
+
|
|
81
|
+
def _run_async(self, coro, timeout: int = 300) -> Any:
|
|
82
|
+
"""Run an async coroutine synchronously using the background loop.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
coro: Coroutine to run
|
|
86
|
+
timeout: Timeout in seconds (default: 5 minutes)
|
|
87
|
+
"""
|
|
88
|
+
self._ensure_loop_running()
|
|
89
|
+
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
|
|
90
|
+
return future.result(timeout=timeout)
|
|
91
|
+
|
|
92
|
+
def connect(self, context: str, model: Optional[str] = None) -> None:
|
|
93
|
+
"""Connect to Claude with the full codebase context.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
context: Full codebase context (sent once as system prompt)
|
|
97
|
+
model: Model to use (e.g., 'claude-sonnet-4-5')
|
|
98
|
+
"""
|
|
99
|
+
from scry_run.logging import get_logger
|
|
100
|
+
logger = get_logger()
|
|
101
|
+
|
|
102
|
+
# Store for session restart
|
|
103
|
+
self._context = context
|
|
104
|
+
self._model = model
|
|
105
|
+
|
|
106
|
+
if self._connected:
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
logger.info("Claude backend: Starting persistent session")
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
self._run_async(self._connect_async(context, model))
|
|
113
|
+
self._connected = True
|
|
114
|
+
logger.info("Claude backend: Session connected")
|
|
115
|
+
except Exception as e:
|
|
116
|
+
logger.error(f"Claude backend: Connection failed: {e}")
|
|
117
|
+
raise
|
|
118
|
+
|
|
119
|
+
async def _connect_async(self, context: str, model: Optional[str]) -> None:
|
|
120
|
+
"""Async implementation of connect."""
|
|
121
|
+
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
|
122
|
+
|
|
123
|
+
# Provide full context as system prompt - this is sent only once
|
|
124
|
+
system_prompt = f"""You are an expert Python code generator for the scry-run dynamic code generation system.
|
|
125
|
+
|
|
126
|
+
## Your Role
|
|
127
|
+
|
|
128
|
+
You are generating code for an APPLICATION. The codebase context below describes the application you're working with.
|
|
129
|
+
For each code generation request, you will receive:
|
|
130
|
+
1. The class name and attribute name to generate
|
|
131
|
+
2. Whether it's an instance or class method/property
|
|
132
|
+
|
|
133
|
+
Use the codebase context to understand the application and generate appropriate code.
|
|
134
|
+
|
|
135
|
+
## Codebase Context
|
|
136
|
+
|
|
137
|
+
{context}
|
|
138
|
+
|
|
139
|
+
## Output Format
|
|
140
|
+
|
|
141
|
+
For each generation request, respond ONLY with a JSON object containing:
|
|
142
|
+
- code: The Python code (complete method/property definition with type hints)
|
|
143
|
+
- code_type: One of "method", "property", "classmethod", "staticmethod"
|
|
144
|
+
- docstring: Brief description
|
|
145
|
+
- dependencies: List of import statements needed
|
|
146
|
+
- packages: List of PyPI packages to install (if any)
|
|
147
|
+
|
|
148
|
+
IMPORTANT: Output ONLY the JSON object, no markdown, no explanation, no code fences."""
|
|
149
|
+
|
|
150
|
+
options = ClaudeAgentOptions(
|
|
151
|
+
system_prompt=system_prompt,
|
|
152
|
+
model=model,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
self._client = ClaudeSDKClient(options=options)
|
|
156
|
+
await self._client.connect()
|
|
157
|
+
|
|
158
|
+
def _ensure_connected(self) -> None:
|
|
159
|
+
"""Ensure session is connected, restart if needed."""
|
|
160
|
+
if self._connected and self._client:
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
if self._context is None:
|
|
164
|
+
raise RuntimeError("No context provided. Call connect() first.")
|
|
165
|
+
|
|
166
|
+
# Attempt reconnect with stored context
|
|
167
|
+
from scry_run.logging import get_logger
|
|
168
|
+
logger = get_logger()
|
|
169
|
+
logger.info("Claude backend: Session died, reconnecting with full context...")
|
|
170
|
+
|
|
171
|
+
self._connected = False
|
|
172
|
+
self.connect(self._context, self._model)
|
|
173
|
+
|
|
174
|
+
def query(self, prompt: str, max_retries: int = 3) -> str:
|
|
175
|
+
"""Send a query and collect the full text response.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
prompt: The minimal prompt (just the "frame" - class/method info)
|
|
179
|
+
max_retries: Number of retries on session failure
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
The collected text response from Claude
|
|
183
|
+
"""
|
|
184
|
+
from scry_run.logging import get_logger
|
|
185
|
+
logger = get_logger()
|
|
186
|
+
|
|
187
|
+
last_error: Optional[Exception] = None
|
|
188
|
+
|
|
189
|
+
for attempt in range(max_retries):
|
|
190
|
+
try:
|
|
191
|
+
self._ensure_connected()
|
|
192
|
+
|
|
193
|
+
# Truncate prompt for logging
|
|
194
|
+
if len(prompt) > 500:
|
|
195
|
+
prompt_preview = prompt[:200] + f"\n... [{len(prompt) - 400} chars] ...\n" + prompt[-200:]
|
|
196
|
+
else:
|
|
197
|
+
prompt_preview = prompt
|
|
198
|
+
logger.debug(f"Claude backend: query ({len(prompt)} chars)", f"PROMPT:\n{prompt_preview}")
|
|
199
|
+
|
|
200
|
+
response = self._run_async(self._query_async(prompt))
|
|
201
|
+
|
|
202
|
+
logger.debug(f"Claude backend: response ({len(response)} chars)", f"RESPONSE:\n{response}")
|
|
203
|
+
return response
|
|
204
|
+
|
|
205
|
+
except Exception as e:
|
|
206
|
+
last_error = e
|
|
207
|
+
logger.error(f"Claude backend: Query failed (attempt {attempt + 1}/{max_retries}): {e}")
|
|
208
|
+
|
|
209
|
+
# Mark as disconnected to force reconnect on next attempt
|
|
210
|
+
self._connected = False
|
|
211
|
+
self._client = None
|
|
212
|
+
|
|
213
|
+
if attempt < max_retries - 1:
|
|
214
|
+
logger.info("Claude backend: Retrying with fresh session...")
|
|
215
|
+
|
|
216
|
+
raise RuntimeError(f"Claude query failed after {max_retries} attempts: {last_error}")
|
|
217
|
+
|
|
218
|
+
async def _query_async(self, prompt: str) -> str:
|
|
219
|
+
"""Async implementation of query."""
|
|
220
|
+
from claude_agent_sdk import AssistantMessage, ResultMessage, TextBlock
|
|
221
|
+
|
|
222
|
+
await self._client.query(prompt)
|
|
223
|
+
|
|
224
|
+
# Collect all text from the response
|
|
225
|
+
text_parts = []
|
|
226
|
+
|
|
227
|
+
async for message in self._client.receive_response():
|
|
228
|
+
if isinstance(message, AssistantMessage):
|
|
229
|
+
for block in message.content:
|
|
230
|
+
if isinstance(block, TextBlock):
|
|
231
|
+
text_parts.append(block.text)
|
|
232
|
+
elif isinstance(message, ResultMessage):
|
|
233
|
+
if message.is_error:
|
|
234
|
+
raise RuntimeError(f"Claude query failed: {message.result}")
|
|
235
|
+
break
|
|
236
|
+
|
|
237
|
+
return "".join(text_parts)
|
|
238
|
+
|
|
239
|
+
def disconnect(self) -> None:
|
|
240
|
+
"""Disconnect the session."""
|
|
241
|
+
if not self._connected:
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
from scry_run.logging import get_logger
|
|
245
|
+
logger = get_logger()
|
|
246
|
+
logger.info("Claude backend: Disconnecting session")
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
self._run_async(self._disconnect_async(), timeout=10)
|
|
250
|
+
except Exception as e:
|
|
251
|
+
# Cancel scope errors are expected during atexit (different thread)
|
|
252
|
+
# Just log at debug level and proceed with cleanup
|
|
253
|
+
if "cancel scope" in str(e).lower():
|
|
254
|
+
logger.debug(f"Claude backend: Expected cleanup error: {e}")
|
|
255
|
+
else:
|
|
256
|
+
logger.error(f"Claude backend: Error during disconnect: {e}")
|
|
257
|
+
|
|
258
|
+
self._connected = False
|
|
259
|
+
|
|
260
|
+
async def _disconnect_async(self) -> None:
|
|
261
|
+
"""Async implementation of disconnect."""
|
|
262
|
+
if self._client:
|
|
263
|
+
try:
|
|
264
|
+
await self._client.disconnect()
|
|
265
|
+
except Exception:
|
|
266
|
+
# Ignore errors during disconnect - we're cleaning up anyway
|
|
267
|
+
pass
|
|
268
|
+
self._client = None
|
|
269
|
+
|
|
270
|
+
def reset(self) -> None:
|
|
271
|
+
"""Reset the session (forces full context on next connect)."""
|
|
272
|
+
self.disconnect()
|
|
273
|
+
self._context = None
|
|
274
|
+
|
|
275
|
+
def _shutdown_sync(self) -> None:
|
|
276
|
+
"""Synchronous shutdown for atexit handler."""
|
|
277
|
+
# Mark as disconnected first to prevent further operations
|
|
278
|
+
self._connected = False
|
|
279
|
+
|
|
280
|
+
# Stop the event loop - this is the cleanest way to shut down
|
|
281
|
+
# Don't try to run async disconnect as it causes task issues
|
|
282
|
+
if self._loop is not None:
|
|
283
|
+
try:
|
|
284
|
+
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
285
|
+
except Exception:
|
|
286
|
+
pass # Loop may already be stopped
|
|
287
|
+
|
|
288
|
+
@property
|
|
289
|
+
def is_connected(self) -> bool:
|
|
290
|
+
"""Check if session is connected."""
|
|
291
|
+
return self._connected
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def has_context(self) -> bool:
|
|
295
|
+
"""Check if context has been provided."""
|
|
296
|
+
return self._context is not None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class ClaudeBackend(GeneratorBackend):
|
|
300
|
+
"""Backend that uses persistent ClaudeSDKClient session.
|
|
301
|
+
|
|
302
|
+
Context is provided once at session startup. Subsequent generation
|
|
303
|
+
requests send only the minimal "frame" information.
|
|
304
|
+
"""
|
|
305
|
+
|
|
306
|
+
name = "claude"
|
|
307
|
+
|
|
308
|
+
def __init__(self, model: Optional[str] = None):
|
|
309
|
+
"""Initialize Claude backend.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
model: Model name (e.g., 'claude-sonnet-4-5')
|
|
313
|
+
"""
|
|
314
|
+
self.model = model
|
|
315
|
+
self._session = ClaudeSessionManager()
|
|
316
|
+
|
|
317
|
+
@classmethod
|
|
318
|
+
def is_available(cls) -> bool:
|
|
319
|
+
"""Check if claude-agent-sdk is available."""
|
|
320
|
+
try:
|
|
321
|
+
import claude_agent_sdk
|
|
322
|
+
return True
|
|
323
|
+
except ImportError:
|
|
324
|
+
return shutil.which("claude") is not None
|
|
325
|
+
|
|
326
|
+
def set_context(self, context: str) -> None:
|
|
327
|
+
"""Set the codebase context for this session.
|
|
328
|
+
|
|
329
|
+
This should be called once before any generation requests.
|
|
330
|
+
The context will be sent as the system prompt.
|
|
331
|
+
|
|
332
|
+
Args:
|
|
333
|
+
context: Full codebase context
|
|
334
|
+
"""
|
|
335
|
+
self._session.connect(context, self.model)
|
|
336
|
+
|
|
337
|
+
def generate_text(self, prompt: str) -> str:
|
|
338
|
+
"""Generate freeform text via persistent session.
|
|
339
|
+
|
|
340
|
+
Note: For best results, call set_context() first to establish
|
|
341
|
+
the codebase context.
|
|
342
|
+
"""
|
|
343
|
+
from scry_run.logging import get_logger
|
|
344
|
+
logger = get_logger()
|
|
345
|
+
logger.info("Claude backend: generate_text called")
|
|
346
|
+
|
|
347
|
+
# If no context yet, just send the prompt directly
|
|
348
|
+
if not self._session.has_context:
|
|
349
|
+
self._session.connect("", self.model)
|
|
350
|
+
|
|
351
|
+
return self._session.query(prompt)
|
|
352
|
+
|
|
353
|
+
def generate_code(self, prompt: str) -> GeneratedCode:
|
|
354
|
+
"""Generate structured code via persistent session.
|
|
355
|
+
|
|
356
|
+
The prompt should be the minimal "frame" - which class/method
|
|
357
|
+
to generate. The full context should have been set via set_context().
|
|
358
|
+
|
|
359
|
+
If set_context() wasn't called, the prompt will be used as-is
|
|
360
|
+
(for backward compatibility).
|
|
361
|
+
"""
|
|
362
|
+
from scry_run.logging import get_logger
|
|
363
|
+
logger = get_logger()
|
|
364
|
+
logger.info("Claude backend: generate_code called")
|
|
365
|
+
|
|
366
|
+
# If no context set, use prompt as context (backward compat)
|
|
367
|
+
if not self._session.has_context:
|
|
368
|
+
# Extract context from prompt and use it
|
|
369
|
+
self._session.connect(prompt, self.model)
|
|
370
|
+
# First call sets context, so we send a minimal follow-up
|
|
371
|
+
generation_prompt = "Generate the code as specified in the context above."
|
|
372
|
+
else:
|
|
373
|
+
# Context already set, send just the generation request
|
|
374
|
+
generation_prompt = prompt
|
|
375
|
+
|
|
376
|
+
response = self._session.query(generation_prompt)
|
|
377
|
+
|
|
378
|
+
# Parse JSON response
|
|
379
|
+
try:
|
|
380
|
+
start = response.find("{")
|
|
381
|
+
end = response.rfind("}") + 1
|
|
382
|
+
if start >= 0 and end > start:
|
|
383
|
+
json_str = response[start:end]
|
|
384
|
+
logger.debug(f"Claude backend: extracted JSON ({len(json_str)} chars)", f"JSON:\n{json_str}")
|
|
385
|
+
data = json.loads(json_str)
|
|
386
|
+
else:
|
|
387
|
+
data = json.loads(response)
|
|
388
|
+
except json.JSONDecodeError as e:
|
|
389
|
+
logger.error(f"Claude backend: JSON parse error", f"ERROR: {e}\n\nRESPONSE:\n{response}")
|
|
390
|
+
raise RuntimeError(f"Failed to parse JSON from response: {e}\nResponse: {response[:500]}")
|
|
391
|
+
|
|
392
|
+
logger.info(f"Claude backend: code generated (type={data.get('code_type', 'unknown')}, {len(data.get('code', ''))} chars)")
|
|
393
|
+
|
|
394
|
+
return GeneratedCode(
|
|
395
|
+
code=data.get("code", ""),
|
|
396
|
+
code_type=data.get("code_type", "method"),
|
|
397
|
+
docstring=data.get("docstring", ""),
|
|
398
|
+
dependencies=data.get("dependencies", []),
|
|
399
|
+
packages=data.get("packages", []),
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# Register on import
|
|
404
|
+
register_backend("claude", ClaudeBackend)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Frozen backend - errors on any generation attempt.
|
|
2
|
+
|
|
3
|
+
This backend is used for "baked" apps that should only use cached methods.
|
|
4
|
+
Any attempt to generate new code will raise FrozenAppError.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from scry_run.backends.base import GeneratorBackend, GeneratedCode
|
|
10
|
+
from scry_run.backends.registry import register_backend
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FrozenAppError(RuntimeError):
|
|
14
|
+
"""Raised when a frozen app attempts to generate code.
|
|
15
|
+
|
|
16
|
+
This error indicates that a method was called that doesn't exist
|
|
17
|
+
in the cache of a baked/frozen app. The app needs to be "unbaked"
|
|
18
|
+
or the method needs to be added to the cache before baking.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, class_name: str, attr_name: str):
|
|
22
|
+
self.class_name = class_name
|
|
23
|
+
self.attr_name = attr_name
|
|
24
|
+
super().__init__(
|
|
25
|
+
f"Frozen app cannot generate code for '{class_name}.{attr_name}'. "
|
|
26
|
+
f"This method is not in the cache. Either:\n"
|
|
27
|
+
f" 1. Run the original app to generate this method, then re-bake\n"
|
|
28
|
+
f" 2. Set _llm_backend to a real backend (e.g., 'claude') to unfreeze"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FrozenBackend(GeneratorBackend):
|
|
33
|
+
"""Backend that refuses to generate any code.
|
|
34
|
+
|
|
35
|
+
Used for baked apps where all methods should come from cache.
|
|
36
|
+
Any generation attempt raises FrozenAppError with helpful message.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
name = "frozen"
|
|
40
|
+
|
|
41
|
+
def __init__(self, model: Optional[str] = None):
|
|
42
|
+
"""Initialize frozen backend.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
model: Ignored (frozen backend doesn't use models)
|
|
46
|
+
"""
|
|
47
|
+
self.model = model
|
|
48
|
+
# Store context about what's being generated for error messages
|
|
49
|
+
self._current_class: Optional[str] = None
|
|
50
|
+
self._current_attr: Optional[str] = None
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def is_available(cls) -> bool:
|
|
54
|
+
"""Frozen backend is always available."""
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
def generate_text(self, prompt: str) -> str:
|
|
58
|
+
"""Raise error - frozen apps cannot generate text."""
|
|
59
|
+
raise FrozenAppError(
|
|
60
|
+
self._current_class or "Unknown",
|
|
61
|
+
self._current_attr or "unknown"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def generate_code(self, prompt: str) -> GeneratedCode:
|
|
65
|
+
"""Raise error - frozen apps cannot generate code."""
|
|
66
|
+
raise FrozenAppError(
|
|
67
|
+
self._current_class or "Unknown",
|
|
68
|
+
self._current_attr or "unknown"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def set_generation_context(self, class_name: str, attr_name: str) -> None:
|
|
72
|
+
"""Set context for error messages.
|
|
73
|
+
|
|
74
|
+
Called by the generator before attempting generation.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
class_name: Name of the class being generated for
|
|
78
|
+
attr_name: Name of the attribute being generated
|
|
79
|
+
"""
|
|
80
|
+
self._current_class = class_name
|
|
81
|
+
self._current_attr = attr_name
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# Register the backend
|
|
85
|
+
register_backend("frozen", FrozenBackend)
|