yuho 5.0.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.
- yuho/__init__.py +16 -0
- yuho/ast/__init__.py +196 -0
- yuho/ast/builder.py +926 -0
- yuho/ast/constant_folder.py +280 -0
- yuho/ast/dead_code.py +199 -0
- yuho/ast/exhaustiveness.py +503 -0
- yuho/ast/nodes.py +907 -0
- yuho/ast/overlap.py +291 -0
- yuho/ast/reachability.py +293 -0
- yuho/ast/scope_analysis.py +490 -0
- yuho/ast/transformer.py +490 -0
- yuho/ast/type_check.py +471 -0
- yuho/ast/type_inference.py +425 -0
- yuho/ast/visitor.py +239 -0
- yuho/cli/__init__.py +14 -0
- yuho/cli/commands/__init__.py +1 -0
- yuho/cli/commands/api.py +431 -0
- yuho/cli/commands/ast_viz.py +334 -0
- yuho/cli/commands/check.py +218 -0
- yuho/cli/commands/config.py +311 -0
- yuho/cli/commands/contribute.py +122 -0
- yuho/cli/commands/diff.py +487 -0
- yuho/cli/commands/explain.py +240 -0
- yuho/cli/commands/fmt.py +253 -0
- yuho/cli/commands/generate.py +316 -0
- yuho/cli/commands/graph.py +410 -0
- yuho/cli/commands/init.py +120 -0
- yuho/cli/commands/library.py +656 -0
- yuho/cli/commands/lint.py +503 -0
- yuho/cli/commands/lsp.py +36 -0
- yuho/cli/commands/preview.py +377 -0
- yuho/cli/commands/repl.py +444 -0
- yuho/cli/commands/serve.py +44 -0
- yuho/cli/commands/test.py +528 -0
- yuho/cli/commands/transpile.py +121 -0
- yuho/cli/commands/wizard.py +370 -0
- yuho/cli/completions.py +182 -0
- yuho/cli/error_formatter.py +193 -0
- yuho/cli/main.py +1064 -0
- yuho/config/__init__.py +46 -0
- yuho/config/loader.py +235 -0
- yuho/config/mask.py +194 -0
- yuho/config/schema.py +147 -0
- yuho/library/__init__.py +84 -0
- yuho/library/index.py +328 -0
- yuho/library/install.py +699 -0
- yuho/library/lockfile.py +330 -0
- yuho/library/package.py +421 -0
- yuho/library/resolver.py +791 -0
- yuho/library/signature.py +335 -0
- yuho/llm/__init__.py +45 -0
- yuho/llm/config.py +75 -0
- yuho/llm/factory.py +123 -0
- yuho/llm/prompts.py +146 -0
- yuho/llm/providers.py +383 -0
- yuho/llm/utils.py +470 -0
- yuho/lsp/__init__.py +14 -0
- yuho/lsp/code_action_handler.py +518 -0
- yuho/lsp/completion_handler.py +85 -0
- yuho/lsp/diagnostics.py +100 -0
- yuho/lsp/hover_handler.py +130 -0
- yuho/lsp/server.py +1425 -0
- yuho/mcp/__init__.py +10 -0
- yuho/mcp/server.py +1452 -0
- yuho/parser/__init__.py +8 -0
- yuho/parser/source_location.py +108 -0
- yuho/parser/wrapper.py +311 -0
- yuho/testing/__init__.py +48 -0
- yuho/testing/coverage.py +274 -0
- yuho/testing/fixtures.py +263 -0
- yuho/transpile/__init__.py +52 -0
- yuho/transpile/alloy_transpiler.py +546 -0
- yuho/transpile/base.py +100 -0
- yuho/transpile/blocks_transpiler.py +338 -0
- yuho/transpile/english_transpiler.py +470 -0
- yuho/transpile/graphql_transpiler.py +404 -0
- yuho/transpile/json_transpiler.py +217 -0
- yuho/transpile/jsonld_transpiler.py +250 -0
- yuho/transpile/latex_preamble.py +161 -0
- yuho/transpile/latex_transpiler.py +406 -0
- yuho/transpile/latex_utils.py +206 -0
- yuho/transpile/mermaid_transpiler.py +357 -0
- yuho/transpile/registry.py +275 -0
- yuho/verify/__init__.py +43 -0
- yuho/verify/alloy.py +352 -0
- yuho/verify/combined.py +218 -0
- yuho/verify/z3_solver.py +1155 -0
- yuho-5.0.0.dist-info/METADATA +186 -0
- yuho-5.0.0.dist-info/RECORD +91 -0
- yuho-5.0.0.dist-info/WHEEL +4 -0
- yuho-5.0.0.dist-info/entry_points.txt +2 -0
yuho/llm/utils.py
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LLM response caching and rate limiting utilities.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Dict, Any, Optional, Callable
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
import threading
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class CacheEntry:
|
|
19
|
+
"""A cached LLM response."""
|
|
20
|
+
response: str
|
|
21
|
+
prompt_hash: str
|
|
22
|
+
timestamp: datetime
|
|
23
|
+
hit_count: int = 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ResponseCache:
|
|
27
|
+
"""
|
|
28
|
+
LRU cache for LLM responses using content-hash keys.
|
|
29
|
+
|
|
30
|
+
Caches responses based on a hash of the prompt, allowing
|
|
31
|
+
repeated queries to avoid redundant LLM calls.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, max_size: int = 1000, ttl_seconds: int = 3600):
|
|
35
|
+
"""
|
|
36
|
+
Initialize the response cache.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
max_size: Maximum number of entries to cache
|
|
40
|
+
ttl_seconds: Time-to-live for cache entries in seconds
|
|
41
|
+
"""
|
|
42
|
+
self._cache: Dict[str, CacheEntry] = {}
|
|
43
|
+
self._max_size = max_size
|
|
44
|
+
self._ttl = timedelta(seconds=ttl_seconds)
|
|
45
|
+
self._lock = threading.Lock()
|
|
46
|
+
self._hits = 0
|
|
47
|
+
self._misses = 0
|
|
48
|
+
|
|
49
|
+
def _hash_prompt(self, prompt: str, **kwargs) -> str:
|
|
50
|
+
"""Generate a content hash for a prompt and parameters."""
|
|
51
|
+
# Include relevant kwargs in hash
|
|
52
|
+
hash_content = json.dumps({
|
|
53
|
+
"prompt": prompt,
|
|
54
|
+
**{k: v for k, v in sorted(kwargs.items())},
|
|
55
|
+
}, sort_keys=True)
|
|
56
|
+
return hashlib.sha256(hash_content.encode()).hexdigest()[:16]
|
|
57
|
+
|
|
58
|
+
def get(self, prompt: str, **kwargs) -> Optional[str]:
|
|
59
|
+
"""
|
|
60
|
+
Get a cached response for a prompt.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
prompt: The prompt to look up
|
|
64
|
+
**kwargs: Additional parameters that affect the response
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Cached response or None if not found/expired
|
|
68
|
+
"""
|
|
69
|
+
key = self._hash_prompt(prompt, **kwargs)
|
|
70
|
+
|
|
71
|
+
with self._lock:
|
|
72
|
+
if key not in self._cache:
|
|
73
|
+
self._misses += 1
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
entry = self._cache[key]
|
|
77
|
+
|
|
78
|
+
# Check if expired
|
|
79
|
+
if datetime.now() - entry.timestamp > self._ttl:
|
|
80
|
+
del self._cache[key]
|
|
81
|
+
self._misses += 1
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
# Update hit count and move to end (LRU)
|
|
85
|
+
entry.hit_count += 1
|
|
86
|
+
self._cache[key] = entry # Move to end
|
|
87
|
+
self._hits += 1
|
|
88
|
+
|
|
89
|
+
logger.debug(f"Cache hit for prompt hash {key}")
|
|
90
|
+
return entry.response
|
|
91
|
+
|
|
92
|
+
def put(self, prompt: str, response: str, **kwargs) -> None:
|
|
93
|
+
"""
|
|
94
|
+
Store a response in the cache.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
prompt: The prompt that produced the response
|
|
98
|
+
response: The LLM response to cache
|
|
99
|
+
**kwargs: Additional parameters that affect the response
|
|
100
|
+
"""
|
|
101
|
+
key = self._hash_prompt(prompt, **kwargs)
|
|
102
|
+
|
|
103
|
+
with self._lock:
|
|
104
|
+
# Evict oldest entries if at capacity
|
|
105
|
+
while len(self._cache) >= self._max_size:
|
|
106
|
+
oldest_key = next(iter(self._cache))
|
|
107
|
+
del self._cache[oldest_key]
|
|
108
|
+
logger.debug(f"Evicted cache entry {oldest_key}")
|
|
109
|
+
|
|
110
|
+
self._cache[key] = CacheEntry(
|
|
111
|
+
response=response,
|
|
112
|
+
prompt_hash=key,
|
|
113
|
+
timestamp=datetime.now(),
|
|
114
|
+
)
|
|
115
|
+
logger.debug(f"Cached response for prompt hash {key}")
|
|
116
|
+
|
|
117
|
+
def invalidate(self, prompt: str, **kwargs) -> bool:
|
|
118
|
+
"""
|
|
119
|
+
Remove a specific entry from the cache.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
True if an entry was removed, False otherwise
|
|
123
|
+
"""
|
|
124
|
+
key = self._hash_prompt(prompt, **kwargs)
|
|
125
|
+
|
|
126
|
+
with self._lock:
|
|
127
|
+
if key in self._cache:
|
|
128
|
+
del self._cache[key]
|
|
129
|
+
return True
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
def clear(self) -> int:
|
|
133
|
+
"""
|
|
134
|
+
Clear all cache entries.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Number of entries cleared
|
|
138
|
+
"""
|
|
139
|
+
with self._lock:
|
|
140
|
+
count = len(self._cache)
|
|
141
|
+
self._cache.clear()
|
|
142
|
+
return count
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def stats(self) -> Dict[str, Any]:
|
|
146
|
+
"""Get cache statistics."""
|
|
147
|
+
with self._lock:
|
|
148
|
+
total = self._hits + self._misses
|
|
149
|
+
hit_rate = self._hits / total if total > 0 else 0.0
|
|
150
|
+
return {
|
|
151
|
+
"size": len(self._cache),
|
|
152
|
+
"max_size": self._max_size,
|
|
153
|
+
"hits": self._hits,
|
|
154
|
+
"misses": self._misses,
|
|
155
|
+
"hit_rate": hit_rate,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass
|
|
160
|
+
class RateLimitState:
|
|
161
|
+
"""State for tracking rate limit window."""
|
|
162
|
+
requests: int = 0
|
|
163
|
+
window_start: float = field(default_factory=time.time)
|
|
164
|
+
backoff_until: float = 0.0
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class RateLimiter:
|
|
168
|
+
"""
|
|
169
|
+
Rate limiter with exponential backoff for cloud API providers.
|
|
170
|
+
|
|
171
|
+
Tracks request counts within time windows and enforces
|
|
172
|
+
limits with configurable backoff on 429 responses.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
def __init__(
|
|
176
|
+
self,
|
|
177
|
+
requests_per_minute: int = 60,
|
|
178
|
+
requests_per_day: int = 10000,
|
|
179
|
+
base_backoff_seconds: float = 1.0,
|
|
180
|
+
max_backoff_seconds: float = 60.0,
|
|
181
|
+
backoff_multiplier: float = 2.0,
|
|
182
|
+
):
|
|
183
|
+
"""
|
|
184
|
+
Initialize the rate limiter.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
requests_per_minute: Maximum requests per minute
|
|
188
|
+
requests_per_day: Maximum requests per day
|
|
189
|
+
base_backoff_seconds: Initial backoff duration
|
|
190
|
+
max_backoff_seconds: Maximum backoff duration
|
|
191
|
+
backoff_multiplier: Multiplier for exponential backoff
|
|
192
|
+
"""
|
|
193
|
+
self.rpm_limit = requests_per_minute
|
|
194
|
+
self.rpd_limit = requests_per_day
|
|
195
|
+
self.base_backoff = base_backoff_seconds
|
|
196
|
+
self.max_backoff = max_backoff_seconds
|
|
197
|
+
self.backoff_multiplier = backoff_multiplier
|
|
198
|
+
|
|
199
|
+
self._minute_state = RateLimitState()
|
|
200
|
+
self._day_state = RateLimitState()
|
|
201
|
+
self._current_backoff = base_backoff_seconds
|
|
202
|
+
self._consecutive_failures = 0
|
|
203
|
+
self._lock = threading.Lock()
|
|
204
|
+
|
|
205
|
+
def _reset_window_if_needed(self, state: RateLimitState, window_seconds: float) -> None:
|
|
206
|
+
"""Reset request count if window has elapsed."""
|
|
207
|
+
now = time.time()
|
|
208
|
+
if now - state.window_start >= window_seconds:
|
|
209
|
+
state.requests = 0
|
|
210
|
+
state.window_start = now
|
|
211
|
+
|
|
212
|
+
def acquire(self) -> float:
|
|
213
|
+
"""
|
|
214
|
+
Acquire permission to make a request.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
Number of seconds to wait before making the request.
|
|
218
|
+
Returns 0.0 if request can proceed immediately.
|
|
219
|
+
"""
|
|
220
|
+
with self._lock:
|
|
221
|
+
now = time.time()
|
|
222
|
+
|
|
223
|
+
# Check if in backoff period
|
|
224
|
+
if now < self._minute_state.backoff_until:
|
|
225
|
+
wait_time = self._minute_state.backoff_until - now
|
|
226
|
+
logger.warning(f"Rate limiter: backoff active, wait {wait_time:.2f}s")
|
|
227
|
+
return wait_time
|
|
228
|
+
|
|
229
|
+
# Reset windows if needed
|
|
230
|
+
self._reset_window_if_needed(self._minute_state, 60.0)
|
|
231
|
+
self._reset_window_if_needed(self._day_state, 86400.0)
|
|
232
|
+
|
|
233
|
+
# Check minute limit
|
|
234
|
+
if self._minute_state.requests >= self.rpm_limit:
|
|
235
|
+
wait_time = 60.0 - (now - self._minute_state.window_start)
|
|
236
|
+
logger.warning(f"Rate limit reached (RPM), wait {wait_time:.2f}s")
|
|
237
|
+
return max(0.0, wait_time)
|
|
238
|
+
|
|
239
|
+
# Check day limit
|
|
240
|
+
if self._day_state.requests >= self.rpd_limit:
|
|
241
|
+
wait_time = 86400.0 - (now - self._day_state.window_start)
|
|
242
|
+
logger.warning(f"Rate limit reached (RPD), wait {wait_time:.2f}s")
|
|
243
|
+
return max(0.0, wait_time)
|
|
244
|
+
|
|
245
|
+
# Increment counters
|
|
246
|
+
self._minute_state.requests += 1
|
|
247
|
+
self._day_state.requests += 1
|
|
248
|
+
|
|
249
|
+
return 0.0
|
|
250
|
+
|
|
251
|
+
def report_success(self) -> None:
|
|
252
|
+
"""Report a successful request, resetting backoff."""
|
|
253
|
+
with self._lock:
|
|
254
|
+
self._consecutive_failures = 0
|
|
255
|
+
self._current_backoff = self.base_backoff
|
|
256
|
+
|
|
257
|
+
def report_rate_limited(self) -> float:
|
|
258
|
+
"""
|
|
259
|
+
Report a rate limit response (429), triggering backoff.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
Recommended wait time before retry
|
|
263
|
+
"""
|
|
264
|
+
with self._lock:
|
|
265
|
+
self._consecutive_failures += 1
|
|
266
|
+
|
|
267
|
+
# Calculate exponential backoff
|
|
268
|
+
backoff = min(
|
|
269
|
+
self.base_backoff * (self.backoff_multiplier ** self._consecutive_failures),
|
|
270
|
+
self.max_backoff,
|
|
271
|
+
)
|
|
272
|
+
self._current_backoff = backoff
|
|
273
|
+
|
|
274
|
+
# Set backoff period
|
|
275
|
+
now = time.time()
|
|
276
|
+
self._minute_state.backoff_until = now + backoff
|
|
277
|
+
|
|
278
|
+
logger.warning(
|
|
279
|
+
f"Rate limited: backoff {backoff:.2f}s "
|
|
280
|
+
f"(failure #{self._consecutive_failures})"
|
|
281
|
+
)
|
|
282
|
+
return backoff
|
|
283
|
+
|
|
284
|
+
@property
|
|
285
|
+
def stats(self) -> Dict[str, Any]:
|
|
286
|
+
"""Get rate limiter statistics."""
|
|
287
|
+
with self._lock:
|
|
288
|
+
now = time.time()
|
|
289
|
+
return {
|
|
290
|
+
"requests_this_minute": self._minute_state.requests,
|
|
291
|
+
"requests_this_day": self._day_state.requests,
|
|
292
|
+
"rpm_limit": self.rpm_limit,
|
|
293
|
+
"rpd_limit": self.rpd_limit,
|
|
294
|
+
"current_backoff": self._current_backoff,
|
|
295
|
+
"consecutive_failures": self._consecutive_failures,
|
|
296
|
+
"in_backoff": now < self._minute_state.backoff_until,
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class TokenCounter:
|
|
301
|
+
"""
|
|
302
|
+
Token counting for context window management.
|
|
303
|
+
|
|
304
|
+
Provides approximate token counts using simple heuristics
|
|
305
|
+
or exact counts via tiktoken if available.
|
|
306
|
+
"""
|
|
307
|
+
|
|
308
|
+
def __init__(self, model: str = "gpt-4"):
|
|
309
|
+
"""
|
|
310
|
+
Initialize the token counter.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
model: Model name for accurate tokenization (if tiktoken available)
|
|
314
|
+
"""
|
|
315
|
+
self.model = model
|
|
316
|
+
self._encoding = None
|
|
317
|
+
self._use_tiktoken = False
|
|
318
|
+
|
|
319
|
+
# Try to load tiktoken for accurate counting
|
|
320
|
+
try:
|
|
321
|
+
import tiktoken
|
|
322
|
+
try:
|
|
323
|
+
self._encoding = tiktoken.encoding_for_model(model)
|
|
324
|
+
self._use_tiktoken = True
|
|
325
|
+
logger.debug(f"Using tiktoken for {model}")
|
|
326
|
+
except KeyError:
|
|
327
|
+
self._encoding = tiktoken.get_encoding("cl100k_base")
|
|
328
|
+
self._use_tiktoken = True
|
|
329
|
+
logger.debug("Using tiktoken with cl100k_base encoding")
|
|
330
|
+
except ImportError:
|
|
331
|
+
logger.debug("tiktoken not available, using approximate counting")
|
|
332
|
+
|
|
333
|
+
def count(self, text: str) -> int:
|
|
334
|
+
"""
|
|
335
|
+
Count tokens in text.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
text: Text to count tokens in
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
Token count (exact with tiktoken, approximate otherwise)
|
|
342
|
+
"""
|
|
343
|
+
if self._use_tiktoken and self._encoding:
|
|
344
|
+
return len(self._encoding.encode(text))
|
|
345
|
+
|
|
346
|
+
# Approximate: ~4 chars per token (conservative estimate)
|
|
347
|
+
return max(1, len(text) // 4)
|
|
348
|
+
|
|
349
|
+
def fits_context(self, text: str, context_window: int, reserved: int = 1000) -> bool:
|
|
350
|
+
"""
|
|
351
|
+
Check if text fits within context window.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
text: Text to check
|
|
355
|
+
context_window: Maximum context window size
|
|
356
|
+
reserved: Tokens reserved for response
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
True if text fits, False otherwise
|
|
360
|
+
"""
|
|
361
|
+
token_count = self.count(text)
|
|
362
|
+
available = context_window - reserved
|
|
363
|
+
return token_count <= available
|
|
364
|
+
|
|
365
|
+
def truncate_to_fit(self, text: str, max_tokens: int) -> str:
|
|
366
|
+
"""
|
|
367
|
+
Truncate text to fit within token limit.
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
text: Text to truncate
|
|
371
|
+
max_tokens: Maximum allowed tokens
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
Truncated text
|
|
375
|
+
"""
|
|
376
|
+
if self._use_tiktoken and self._encoding:
|
|
377
|
+
tokens = self._encoding.encode(text)
|
|
378
|
+
if len(tokens) <= max_tokens:
|
|
379
|
+
return text
|
|
380
|
+
return self._encoding.decode(tokens[:max_tokens])
|
|
381
|
+
|
|
382
|
+
# Approximate: ~4 chars per token
|
|
383
|
+
max_chars = max_tokens * 4
|
|
384
|
+
if len(text) <= max_chars:
|
|
385
|
+
return text
|
|
386
|
+
return text[:max_chars] + "..."
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
class PromptCompressor:
|
|
390
|
+
"""
|
|
391
|
+
Compress prompts for long statutes exceeding context window.
|
|
392
|
+
|
|
393
|
+
Strategies:
|
|
394
|
+
- Remove comments and whitespace
|
|
395
|
+
- Summarize sections
|
|
396
|
+
- Split into chunks
|
|
397
|
+
"""
|
|
398
|
+
|
|
399
|
+
def __init__(self, token_counter: Optional[TokenCounter] = None):
|
|
400
|
+
"""Initialize the prompt compressor."""
|
|
401
|
+
self.token_counter = token_counter or TokenCounter()
|
|
402
|
+
|
|
403
|
+
def compress(
|
|
404
|
+
self,
|
|
405
|
+
text: str,
|
|
406
|
+
max_tokens: int,
|
|
407
|
+
preserve_structure: bool = True,
|
|
408
|
+
) -> str:
|
|
409
|
+
"""
|
|
410
|
+
Compress text to fit within token limit.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
text: Text to compress
|
|
414
|
+
max_tokens: Maximum allowed tokens
|
|
415
|
+
preserve_structure: Keep structural elements (headers, etc.)
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
Compressed text
|
|
419
|
+
"""
|
|
420
|
+
current_count = self.token_counter.count(text)
|
|
421
|
+
if current_count <= max_tokens:
|
|
422
|
+
return text
|
|
423
|
+
|
|
424
|
+
# Strategy 1: Remove excessive whitespace
|
|
425
|
+
compressed = self._remove_excessive_whitespace(text)
|
|
426
|
+
if self.token_counter.count(compressed) <= max_tokens:
|
|
427
|
+
return compressed
|
|
428
|
+
|
|
429
|
+
# Strategy 2: Remove comments
|
|
430
|
+
compressed = self._remove_comments(compressed)
|
|
431
|
+
if self.token_counter.count(compressed) <= max_tokens:
|
|
432
|
+
return compressed
|
|
433
|
+
|
|
434
|
+
# Strategy 3: Truncate with summary
|
|
435
|
+
if preserve_structure:
|
|
436
|
+
return self._truncate_with_summary(compressed, max_tokens)
|
|
437
|
+
|
|
438
|
+
# Final fallback: direct truncation
|
|
439
|
+
return self.token_counter.truncate_to_fit(compressed, max_tokens)
|
|
440
|
+
|
|
441
|
+
def _remove_excessive_whitespace(self, text: str) -> str:
|
|
442
|
+
"""Remove excessive whitespace while preserving structure."""
|
|
443
|
+
import re
|
|
444
|
+
# Replace multiple blank lines with single
|
|
445
|
+
text = re.sub(r'\n\s*\n\s*\n', '\n\n', text)
|
|
446
|
+
# Remove trailing whitespace
|
|
447
|
+
text = '\n'.join(line.rstrip() for line in text.splitlines())
|
|
448
|
+
return text
|
|
449
|
+
|
|
450
|
+
def _remove_comments(self, text: str) -> str:
|
|
451
|
+
"""Remove comment lines from Yuho code."""
|
|
452
|
+
lines = text.splitlines()
|
|
453
|
+
non_comment_lines = [
|
|
454
|
+
line for line in lines
|
|
455
|
+
if not line.strip().startswith('#') and not line.strip().startswith('//')
|
|
456
|
+
]
|
|
457
|
+
return '\n'.join(non_comment_lines)
|
|
458
|
+
|
|
459
|
+
def _truncate_with_summary(self, text: str, max_tokens: int) -> str:
|
|
460
|
+
"""Truncate text but include a summary notice."""
|
|
461
|
+
# Reserve some tokens for summary notice
|
|
462
|
+
summary_notice = "\n\n[... content truncated for context window ...]\n"
|
|
463
|
+
summary_tokens = self.token_counter.count(summary_notice)
|
|
464
|
+
|
|
465
|
+
available = max_tokens - summary_tokens
|
|
466
|
+
if available <= 0:
|
|
467
|
+
return summary_notice
|
|
468
|
+
|
|
469
|
+
truncated = self.token_counter.truncate_to_fit(text, available)
|
|
470
|
+
return truncated + summary_notice
|
yuho/lsp/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Yuho LSP (Language Server Protocol) module.
|
|
3
|
+
|
|
4
|
+
Provides IDE features for .yh files:
|
|
5
|
+
- Syntax error diagnostics
|
|
6
|
+
- Code completion
|
|
7
|
+
- Hover information
|
|
8
|
+
- Go to definition
|
|
9
|
+
- Find references
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from yuho.lsp.server import YuhoLanguageServer
|
|
13
|
+
|
|
14
|
+
__all__ = ["YuhoLanguageServer"]
|