codexa 0.4.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.
- codexa-0.4.0.dist-info/METADATA +650 -0
- codexa-0.4.0.dist-info/RECORD +189 -0
- codexa-0.4.0.dist-info/WHEEL +5 -0
- codexa-0.4.0.dist-info/entry_points.txt +2 -0
- codexa-0.4.0.dist-info/licenses/LICENSE +21 -0
- codexa-0.4.0.dist-info/top_level.txt +1 -0
- semantic_code_intelligence/__init__.py +5 -0
- semantic_code_intelligence/analysis/__init__.py +21 -0
- semantic_code_intelligence/analysis/ai_features.py +351 -0
- semantic_code_intelligence/bridge/__init__.py +28 -0
- semantic_code_intelligence/bridge/context_provider.py +245 -0
- semantic_code_intelligence/bridge/protocol.py +167 -0
- semantic_code_intelligence/bridge/server.py +348 -0
- semantic_code_intelligence/bridge/vscode.py +271 -0
- semantic_code_intelligence/ci/__init__.py +13 -0
- semantic_code_intelligence/ci/hooks.py +98 -0
- semantic_code_intelligence/ci/hotspots.py +272 -0
- semantic_code_intelligence/ci/impact.py +246 -0
- semantic_code_intelligence/ci/metrics.py +591 -0
- semantic_code_intelligence/ci/pr.py +412 -0
- semantic_code_intelligence/ci/quality.py +557 -0
- semantic_code_intelligence/ci/templates.py +164 -0
- semantic_code_intelligence/ci/trace.py +224 -0
- semantic_code_intelligence/cli/__init__.py +0 -0
- semantic_code_intelligence/cli/commands/__init__.py +0 -0
- semantic_code_intelligence/cli/commands/ask_cmd.py +153 -0
- semantic_code_intelligence/cli/commands/benchmark_cmd.py +303 -0
- semantic_code_intelligence/cli/commands/chat_cmd.py +252 -0
- semantic_code_intelligence/cli/commands/ci_gen_cmd.py +74 -0
- semantic_code_intelligence/cli/commands/context_cmd.py +120 -0
- semantic_code_intelligence/cli/commands/cross_refactor_cmd.py +113 -0
- semantic_code_intelligence/cli/commands/deps_cmd.py +91 -0
- semantic_code_intelligence/cli/commands/docs_cmd.py +101 -0
- semantic_code_intelligence/cli/commands/doctor_cmd.py +147 -0
- semantic_code_intelligence/cli/commands/evolve_cmd.py +171 -0
- semantic_code_intelligence/cli/commands/explain_cmd.py +112 -0
- semantic_code_intelligence/cli/commands/gate_cmd.py +135 -0
- semantic_code_intelligence/cli/commands/grep_cmd.py +234 -0
- semantic_code_intelligence/cli/commands/hotspots_cmd.py +119 -0
- semantic_code_intelligence/cli/commands/impact_cmd.py +131 -0
- semantic_code_intelligence/cli/commands/index_cmd.py +138 -0
- semantic_code_intelligence/cli/commands/init_cmd.py +152 -0
- semantic_code_intelligence/cli/commands/investigate_cmd.py +163 -0
- semantic_code_intelligence/cli/commands/languages_cmd.py +101 -0
- semantic_code_intelligence/cli/commands/lsp_cmd.py +49 -0
- semantic_code_intelligence/cli/commands/mcp_cmd.py +50 -0
- semantic_code_intelligence/cli/commands/metrics_cmd.py +264 -0
- semantic_code_intelligence/cli/commands/models_cmd.py +157 -0
- semantic_code_intelligence/cli/commands/plugin_cmd.py +275 -0
- semantic_code_intelligence/cli/commands/pr_summary_cmd.py +178 -0
- semantic_code_intelligence/cli/commands/quality_cmd.py +208 -0
- semantic_code_intelligence/cli/commands/refactor_cmd.py +103 -0
- semantic_code_intelligence/cli/commands/review_cmd.py +88 -0
- semantic_code_intelligence/cli/commands/search_cmd.py +236 -0
- semantic_code_intelligence/cli/commands/serve_cmd.py +117 -0
- semantic_code_intelligence/cli/commands/suggest_cmd.py +100 -0
- semantic_code_intelligence/cli/commands/summary_cmd.py +78 -0
- semantic_code_intelligence/cli/commands/tool_cmd.py +282 -0
- semantic_code_intelligence/cli/commands/trace_cmd.py +123 -0
- semantic_code_intelligence/cli/commands/tui_cmd.py +58 -0
- semantic_code_intelligence/cli/commands/viz_cmd.py +127 -0
- semantic_code_intelligence/cli/commands/watch_cmd.py +72 -0
- semantic_code_intelligence/cli/commands/web_cmd.py +61 -0
- semantic_code_intelligence/cli/commands/workspace_cmd.py +250 -0
- semantic_code_intelligence/cli/main.py +65 -0
- semantic_code_intelligence/cli/router.py +92 -0
- semantic_code_intelligence/config/__init__.py +0 -0
- semantic_code_intelligence/config/settings.py +260 -0
- semantic_code_intelligence/context/__init__.py +19 -0
- semantic_code_intelligence/context/engine.py +429 -0
- semantic_code_intelligence/context/memory.py +253 -0
- semantic_code_intelligence/daemon/__init__.py +1 -0
- semantic_code_intelligence/daemon/watcher.py +515 -0
- semantic_code_intelligence/docs/__init__.py +1080 -0
- semantic_code_intelligence/embeddings/__init__.py +0 -0
- semantic_code_intelligence/embeddings/enhanced.py +131 -0
- semantic_code_intelligence/embeddings/generator.py +149 -0
- semantic_code_intelligence/embeddings/model_registry.py +100 -0
- semantic_code_intelligence/evolution/__init__.py +1 -0
- semantic_code_intelligence/evolution/budget_guard.py +111 -0
- semantic_code_intelligence/evolution/commit_manager.py +88 -0
- semantic_code_intelligence/evolution/context_builder.py +131 -0
- semantic_code_intelligence/evolution/engine.py +249 -0
- semantic_code_intelligence/evolution/patch_generator.py +229 -0
- semantic_code_intelligence/evolution/task_selector.py +214 -0
- semantic_code_intelligence/evolution/test_runner.py +111 -0
- semantic_code_intelligence/indexing/__init__.py +0 -0
- semantic_code_intelligence/indexing/chunker.py +174 -0
- semantic_code_intelligence/indexing/parallel.py +86 -0
- semantic_code_intelligence/indexing/scanner.py +146 -0
- semantic_code_intelligence/indexing/semantic_chunker.py +337 -0
- semantic_code_intelligence/llm/__init__.py +62 -0
- semantic_code_intelligence/llm/cache.py +219 -0
- semantic_code_intelligence/llm/cached_provider.py +145 -0
- semantic_code_intelligence/llm/conversation.py +190 -0
- semantic_code_intelligence/llm/cross_refactor.py +272 -0
- semantic_code_intelligence/llm/investigation.py +274 -0
- semantic_code_intelligence/llm/mock_provider.py +77 -0
- semantic_code_intelligence/llm/ollama_provider.py +122 -0
- semantic_code_intelligence/llm/openai_provider.py +100 -0
- semantic_code_intelligence/llm/provider.py +92 -0
- semantic_code_intelligence/llm/rate_limiter.py +164 -0
- semantic_code_intelligence/llm/reasoning.py +438 -0
- semantic_code_intelligence/llm/safety.py +110 -0
- semantic_code_intelligence/llm/streaming.py +251 -0
- semantic_code_intelligence/lsp/__init__.py +609 -0
- semantic_code_intelligence/mcp/__init__.py +393 -0
- semantic_code_intelligence/parsing/__init__.py +19 -0
- semantic_code_intelligence/parsing/parser.py +375 -0
- semantic_code_intelligence/plugins/__init__.py +255 -0
- semantic_code_intelligence/plugins/examples/__init__.py +1 -0
- semantic_code_intelligence/plugins/examples/code_quality.py +73 -0
- semantic_code_intelligence/plugins/examples/search_annotator.py +56 -0
- semantic_code_intelligence/scalability/__init__.py +205 -0
- semantic_code_intelligence/search/__init__.py +0 -0
- semantic_code_intelligence/search/formatter.py +123 -0
- semantic_code_intelligence/search/grep.py +361 -0
- semantic_code_intelligence/search/hybrid_search.py +170 -0
- semantic_code_intelligence/search/keyword_search.py +311 -0
- semantic_code_intelligence/search/section_expander.py +103 -0
- semantic_code_intelligence/services/__init__.py +0 -0
- semantic_code_intelligence/services/indexing_service.py +630 -0
- semantic_code_intelligence/services/search_service.py +269 -0
- semantic_code_intelligence/storage/__init__.py +0 -0
- semantic_code_intelligence/storage/chunk_hash_store.py +86 -0
- semantic_code_intelligence/storage/hash_store.py +66 -0
- semantic_code_intelligence/storage/index_manifest.py +85 -0
- semantic_code_intelligence/storage/index_stats.py +138 -0
- semantic_code_intelligence/storage/query_history.py +160 -0
- semantic_code_intelligence/storage/symbol_registry.py +209 -0
- semantic_code_intelligence/storage/vector_store.py +297 -0
- semantic_code_intelligence/tests/__init__.py +0 -0
- semantic_code_intelligence/tests/test_ai_features.py +351 -0
- semantic_code_intelligence/tests/test_chunker.py +119 -0
- semantic_code_intelligence/tests/test_cli.py +188 -0
- semantic_code_intelligence/tests/test_config.py +154 -0
- semantic_code_intelligence/tests/test_context.py +381 -0
- semantic_code_intelligence/tests/test_embeddings.py +73 -0
- semantic_code_intelligence/tests/test_endtoend.py +1142 -0
- semantic_code_intelligence/tests/test_enhanced_embeddings.py +92 -0
- semantic_code_intelligence/tests/test_hash_store.py +79 -0
- semantic_code_intelligence/tests/test_logging.py +55 -0
- semantic_code_intelligence/tests/test_new_cli.py +138 -0
- semantic_code_intelligence/tests/test_parser.py +495 -0
- semantic_code_intelligence/tests/test_phase10.py +355 -0
- semantic_code_intelligence/tests/test_phase11.py +593 -0
- semantic_code_intelligence/tests/test_phase12.py +375 -0
- semantic_code_intelligence/tests/test_phase13.py +663 -0
- semantic_code_intelligence/tests/test_phase14.py +568 -0
- semantic_code_intelligence/tests/test_phase15.py +814 -0
- semantic_code_intelligence/tests/test_phase16.py +792 -0
- semantic_code_intelligence/tests/test_phase17.py +815 -0
- semantic_code_intelligence/tests/test_phase18.py +934 -0
- semantic_code_intelligence/tests/test_phase19.py +986 -0
- semantic_code_intelligence/tests/test_phase20.py +2753 -0
- semantic_code_intelligence/tests/test_phase20b.py +2058 -0
- semantic_code_intelligence/tests/test_phase20c.py +962 -0
- semantic_code_intelligence/tests/test_phase21.py +428 -0
- semantic_code_intelligence/tests/test_phase22.py +799 -0
- semantic_code_intelligence/tests/test_phase23.py +783 -0
- semantic_code_intelligence/tests/test_phase24.py +715 -0
- semantic_code_intelligence/tests/test_phase25.py +496 -0
- semantic_code_intelligence/tests/test_phase26.py +251 -0
- semantic_code_intelligence/tests/test_phase27.py +531 -0
- semantic_code_intelligence/tests/test_phase8.py +592 -0
- semantic_code_intelligence/tests/test_phase9.py +643 -0
- semantic_code_intelligence/tests/test_plugins.py +293 -0
- semantic_code_intelligence/tests/test_priority_features.py +727 -0
- semantic_code_intelligence/tests/test_router.py +41 -0
- semantic_code_intelligence/tests/test_scalability.py +138 -0
- semantic_code_intelligence/tests/test_scanner.py +125 -0
- semantic_code_intelligence/tests/test_search.py +160 -0
- semantic_code_intelligence/tests/test_semantic_chunker.py +255 -0
- semantic_code_intelligence/tests/test_tools.py +182 -0
- semantic_code_intelligence/tests/test_vector_store.py +151 -0
- semantic_code_intelligence/tests/test_watcher.py +211 -0
- semantic_code_intelligence/tools/__init__.py +442 -0
- semantic_code_intelligence/tools/executor.py +232 -0
- semantic_code_intelligence/tools/protocol.py +200 -0
- semantic_code_intelligence/tui/__init__.py +454 -0
- semantic_code_intelligence/utils/__init__.py +0 -0
- semantic_code_intelligence/utils/logging.py +112 -0
- semantic_code_intelligence/version.py +3 -0
- semantic_code_intelligence/web/__init__.py +11 -0
- semantic_code_intelligence/web/api.py +289 -0
- semantic_code_intelligence/web/server.py +397 -0
- semantic_code_intelligence/web/ui.py +659 -0
- semantic_code_intelligence/web/visualize.py +226 -0
- semantic_code_intelligence/workspace/__init__.py +427 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""LLM response cache — disk-backed cache with TTL expiration.
|
|
2
|
+
|
|
3
|
+
Caches LLM responses keyed by a deterministic hash of the request
|
|
4
|
+
parameters (provider, model, messages/prompt, temperature, max_tokens).
|
|
5
|
+
Supports time-to-live (TTL) expiration and max-entry eviction.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from semantic_code_intelligence.llm.provider import LLMMessage, LLMResponse
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class CacheEntry:
|
|
22
|
+
"""A single cached LLM response with metadata."""
|
|
23
|
+
|
|
24
|
+
response: dict[str, Any]
|
|
25
|
+
timestamp: float
|
|
26
|
+
provider: str
|
|
27
|
+
model: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class CacheStats:
|
|
32
|
+
"""Cache performance statistics."""
|
|
33
|
+
|
|
34
|
+
hits: int = 0
|
|
35
|
+
misses: int = 0
|
|
36
|
+
evictions: int = 0
|
|
37
|
+
size: int = 0
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def hit_rate(self) -> float:
|
|
41
|
+
"""Return cache hit rate as a percentage."""
|
|
42
|
+
total = self.hits + self.misses
|
|
43
|
+
if total == 0:
|
|
44
|
+
return 0.0
|
|
45
|
+
return (self.hits / total) * 100.0
|
|
46
|
+
|
|
47
|
+
def to_dict(self) -> dict[str, Any]:
|
|
48
|
+
return {
|
|
49
|
+
"hits": self.hits,
|
|
50
|
+
"misses": self.misses,
|
|
51
|
+
"evictions": self.evictions,
|
|
52
|
+
"size": self.size,
|
|
53
|
+
"hit_rate": round(self.hit_rate, 2),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class LLMCache:
|
|
58
|
+
"""Disk-backed LLM response cache with TTL and max-entry limits.
|
|
59
|
+
|
|
60
|
+
Cache entries are evicted when they exceed ``ttl_hours`` or when
|
|
61
|
+
the total number of entries exceeds ``max_entries`` (oldest first).
|
|
62
|
+
The cache is persisted as a JSON file to survive process restarts.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
cache_dir: str | Path | None = None,
|
|
68
|
+
ttl_hours: int = 24,
|
|
69
|
+
max_entries: int = 1000,
|
|
70
|
+
) -> None:
|
|
71
|
+
self._entries: dict[str, dict[str, Any]] = {}
|
|
72
|
+
self._ttl_seconds: float = ttl_hours * 3600.0
|
|
73
|
+
self._max_entries: int = max_entries
|
|
74
|
+
self._stats = CacheStats()
|
|
75
|
+
self._cache_path: Path | None = None
|
|
76
|
+
if cache_dir is not None:
|
|
77
|
+
self._cache_path = Path(cache_dir) / "llm_cache.json"
|
|
78
|
+
self._load()
|
|
79
|
+
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
# Public API
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
def get(
|
|
85
|
+
self,
|
|
86
|
+
provider: str,
|
|
87
|
+
model: str,
|
|
88
|
+
messages: list[LLMMessage] | None = None,
|
|
89
|
+
prompt: str | None = None,
|
|
90
|
+
temperature: float = 0.2,
|
|
91
|
+
max_tokens: int = 2048,
|
|
92
|
+
) -> LLMResponse | None:
|
|
93
|
+
"""Look up a cached response. Returns ``None`` on cache miss."""
|
|
94
|
+
key = self._make_key(provider, model, messages, prompt, temperature, max_tokens)
|
|
95
|
+
entry = self._entries.get(key)
|
|
96
|
+
if entry is None:
|
|
97
|
+
self._stats.misses += 1
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
# Check TTL
|
|
101
|
+
age = time.time() - entry["timestamp"]
|
|
102
|
+
if age > self._ttl_seconds:
|
|
103
|
+
del self._entries[key]
|
|
104
|
+
self._stats.misses += 1
|
|
105
|
+
self._stats.evictions += 1
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
self._stats.hits += 1
|
|
109
|
+
return self._dict_to_response(entry["response"])
|
|
110
|
+
|
|
111
|
+
def put(
|
|
112
|
+
self,
|
|
113
|
+
response: LLMResponse,
|
|
114
|
+
provider: str,
|
|
115
|
+
model: str,
|
|
116
|
+
messages: list[LLMMessage] | None = None,
|
|
117
|
+
prompt: str | None = None,
|
|
118
|
+
temperature: float = 0.2,
|
|
119
|
+
max_tokens: int = 2048,
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Store a response in the cache."""
|
|
122
|
+
key = self._make_key(provider, model, messages, prompt, temperature, max_tokens)
|
|
123
|
+
self._entries[key] = {
|
|
124
|
+
"response": self._response_to_dict(response),
|
|
125
|
+
"timestamp": time.time(),
|
|
126
|
+
"provider": provider,
|
|
127
|
+
"model": model,
|
|
128
|
+
}
|
|
129
|
+
self._evict_if_needed()
|
|
130
|
+
self._stats.size = len(self._entries)
|
|
131
|
+
|
|
132
|
+
def clear(self) -> None:
|
|
133
|
+
"""Remove all cache entries."""
|
|
134
|
+
self._entries.clear()
|
|
135
|
+
self._stats = CacheStats()
|
|
136
|
+
|
|
137
|
+
def save(self) -> None:
|
|
138
|
+
"""Persist cache to disk."""
|
|
139
|
+
if self._cache_path is None:
|
|
140
|
+
return
|
|
141
|
+
self._cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
data = json.dumps(self._entries, indent=2, ensure_ascii=False)
|
|
143
|
+
self._cache_path.write_text(data, encoding="utf-8")
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def stats(self) -> CacheStats:
|
|
147
|
+
"""Return current cache statistics."""
|
|
148
|
+
self._stats.size = len(self._entries)
|
|
149
|
+
return self._stats
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def size(self) -> int:
|
|
153
|
+
return len(self._entries)
|
|
154
|
+
|
|
155
|
+
# ------------------------------------------------------------------
|
|
156
|
+
# Internal helpers
|
|
157
|
+
# ------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
def _load(self) -> None:
|
|
160
|
+
"""Load cache entries from disk, dropping expired entries."""
|
|
161
|
+
if self._cache_path is None or not self._cache_path.exists():
|
|
162
|
+
return
|
|
163
|
+
try:
|
|
164
|
+
raw = json.loads(self._cache_path.read_text(encoding="utf-8"))
|
|
165
|
+
if not isinstance(raw, dict):
|
|
166
|
+
return
|
|
167
|
+
now = time.time()
|
|
168
|
+
for key, entry in raw.items():
|
|
169
|
+
if not isinstance(entry, dict):
|
|
170
|
+
continue
|
|
171
|
+
ts = entry.get("timestamp", 0.0)
|
|
172
|
+
if now - ts <= self._ttl_seconds:
|
|
173
|
+
self._entries[key] = entry
|
|
174
|
+
except (json.JSONDecodeError, OSError):
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
def _evict_if_needed(self) -> None:
|
|
178
|
+
"""Evict oldest entries when the cache exceeds max size."""
|
|
179
|
+
while len(self._entries) > self._max_entries:
|
|
180
|
+
oldest_key = min(self._entries, key=lambda k: self._entries[k]["timestamp"])
|
|
181
|
+
del self._entries[oldest_key]
|
|
182
|
+
self._stats.evictions += 1
|
|
183
|
+
|
|
184
|
+
@staticmethod
|
|
185
|
+
def _make_key(
|
|
186
|
+
provider: str,
|
|
187
|
+
model: str,
|
|
188
|
+
messages: list[LLMMessage] | None,
|
|
189
|
+
prompt: str | None,
|
|
190
|
+
temperature: float,
|
|
191
|
+
max_tokens: int,
|
|
192
|
+
) -> str:
|
|
193
|
+
"""Create a deterministic cache key from request parameters."""
|
|
194
|
+
parts: list[str] = [provider, model, str(temperature), str(max_tokens)]
|
|
195
|
+
if messages is not None:
|
|
196
|
+
for msg in messages:
|
|
197
|
+
parts.append(f"{msg.role.value}:{msg.content}")
|
|
198
|
+
if prompt is not None:
|
|
199
|
+
parts.append(f"prompt:{prompt}")
|
|
200
|
+
raw = "\n".join(parts)
|
|
201
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
202
|
+
|
|
203
|
+
@staticmethod
|
|
204
|
+
def _response_to_dict(response: LLMResponse) -> dict[str, Any]:
|
|
205
|
+
return {
|
|
206
|
+
"content": response.content,
|
|
207
|
+
"model": response.model,
|
|
208
|
+
"provider": response.provider,
|
|
209
|
+
"usage": response.usage,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
@staticmethod
|
|
213
|
+
def _dict_to_response(data: dict[str, Any]) -> LLMResponse:
|
|
214
|
+
return LLMResponse(
|
|
215
|
+
content=data.get("content", ""),
|
|
216
|
+
model=data.get("model", ""),
|
|
217
|
+
provider=data.get("provider", ""),
|
|
218
|
+
usage=data.get("usage", {}),
|
|
219
|
+
)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Cached LLM provider wrapper — adds caching and rate limiting to any provider.
|
|
2
|
+
|
|
3
|
+
``CachedProvider`` wraps an existing :class:`LLMProvider` and transparently
|
|
4
|
+
adds response caching and API-call rate limiting. It is the primary
|
|
5
|
+
integration point for the Phase 22 caching + rate limiting feature.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from semantic_code_intelligence.llm.cache import LLMCache
|
|
13
|
+
from semantic_code_intelligence.llm.provider import LLMMessage, LLMProvider, LLMResponse
|
|
14
|
+
from semantic_code_intelligence.llm.rate_limiter import RateLimiter
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CachedProvider(LLMProvider):
|
|
18
|
+
"""LLM provider wrapper that adds transparent caching and rate limiting.
|
|
19
|
+
|
|
20
|
+
Parameters
|
|
21
|
+
----------
|
|
22
|
+
provider : LLMProvider
|
|
23
|
+
The underlying LLM provider to wrap.
|
|
24
|
+
cache : LLMCache | None
|
|
25
|
+
Response cache instance. ``None`` disables caching.
|
|
26
|
+
rate_limiter : RateLimiter | None
|
|
27
|
+
Rate limiter instance. ``None`` disables rate limiting.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
provider: LLMProvider,
|
|
33
|
+
cache: LLMCache | None = None,
|
|
34
|
+
rate_limiter: RateLimiter | None = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
self._provider = provider
|
|
37
|
+
self._cache = cache
|
|
38
|
+
self._rate_limiter = rate_limiter
|
|
39
|
+
|
|
40
|
+
# ------------------------------------------------------------------
|
|
41
|
+
# LLMProvider interface
|
|
42
|
+
# ------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def name(self) -> str:
|
|
46
|
+
return self._provider.name
|
|
47
|
+
|
|
48
|
+
def complete(self, prompt: str, **kwargs: Any) -> LLMResponse:
|
|
49
|
+
temperature = kwargs.get("temperature", 0.2)
|
|
50
|
+
max_tokens = kwargs.get("max_tokens", 2048)
|
|
51
|
+
|
|
52
|
+
# 1. Check cache
|
|
53
|
+
if self._cache is not None:
|
|
54
|
+
cached = self._cache.get(
|
|
55
|
+
provider=self._provider.name,
|
|
56
|
+
model=getattr(self._provider, "model", ""),
|
|
57
|
+
prompt=prompt,
|
|
58
|
+
temperature=temperature,
|
|
59
|
+
max_tokens=max_tokens,
|
|
60
|
+
)
|
|
61
|
+
if cached is not None:
|
|
62
|
+
return cached
|
|
63
|
+
|
|
64
|
+
# 2. Rate limit
|
|
65
|
+
if self._rate_limiter is not None and self._rate_limiter.is_enabled:
|
|
66
|
+
self._rate_limiter.acquire(estimated_tokens=max_tokens)
|
|
67
|
+
|
|
68
|
+
# 3. Call underlying provider
|
|
69
|
+
response = self._provider.complete(prompt, **kwargs)
|
|
70
|
+
|
|
71
|
+
# 4. Record token usage
|
|
72
|
+
if self._rate_limiter is not None and self._rate_limiter.is_enabled:
|
|
73
|
+
total_tokens = response.usage.get("total_tokens", 0)
|
|
74
|
+
self._rate_limiter.record_usage(total_tokens)
|
|
75
|
+
|
|
76
|
+
# 5. Store in cache
|
|
77
|
+
if self._cache is not None:
|
|
78
|
+
self._cache.put(
|
|
79
|
+
response=response,
|
|
80
|
+
provider=self._provider.name,
|
|
81
|
+
model=getattr(self._provider, "model", ""),
|
|
82
|
+
prompt=prompt,
|
|
83
|
+
temperature=temperature,
|
|
84
|
+
max_tokens=max_tokens,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return response
|
|
88
|
+
|
|
89
|
+
def chat(self, messages: list[LLMMessage], **kwargs: Any) -> LLMResponse:
|
|
90
|
+
temperature = kwargs.get("temperature", 0.2)
|
|
91
|
+
max_tokens = kwargs.get("max_tokens", 2048)
|
|
92
|
+
|
|
93
|
+
# 1. Check cache
|
|
94
|
+
if self._cache is not None:
|
|
95
|
+
cached = self._cache.get(
|
|
96
|
+
provider=self._provider.name,
|
|
97
|
+
model=getattr(self._provider, "model", ""),
|
|
98
|
+
messages=messages,
|
|
99
|
+
temperature=temperature,
|
|
100
|
+
max_tokens=max_tokens,
|
|
101
|
+
)
|
|
102
|
+
if cached is not None:
|
|
103
|
+
return cached
|
|
104
|
+
|
|
105
|
+
# 2. Rate limit
|
|
106
|
+
if self._rate_limiter is not None and self._rate_limiter.is_enabled:
|
|
107
|
+
self._rate_limiter.acquire(estimated_tokens=max_tokens)
|
|
108
|
+
|
|
109
|
+
# 3. Call underlying provider
|
|
110
|
+
response = self._provider.chat(messages, **kwargs)
|
|
111
|
+
|
|
112
|
+
# 4. Record token usage
|
|
113
|
+
if self._rate_limiter is not None and self._rate_limiter.is_enabled:
|
|
114
|
+
total_tokens = response.usage.get("total_tokens", 0)
|
|
115
|
+
self._rate_limiter.record_usage(total_tokens)
|
|
116
|
+
|
|
117
|
+
# 5. Store in cache
|
|
118
|
+
if self._cache is not None:
|
|
119
|
+
self._cache.put(
|
|
120
|
+
response=response,
|
|
121
|
+
provider=self._provider.name,
|
|
122
|
+
model=getattr(self._provider, "model", ""),
|
|
123
|
+
messages=messages,
|
|
124
|
+
temperature=temperature,
|
|
125
|
+
max_tokens=max_tokens,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return response
|
|
129
|
+
|
|
130
|
+
def is_available(self) -> bool:
|
|
131
|
+
return self._provider.is_available()
|
|
132
|
+
|
|
133
|
+
# ------------------------------------------------------------------
|
|
134
|
+
# Extra helpers
|
|
135
|
+
# ------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def inner(self) -> LLMProvider:
|
|
139
|
+
"""Return the wrapped provider."""
|
|
140
|
+
return self._provider
|
|
141
|
+
|
|
142
|
+
def save_cache(self) -> None:
|
|
143
|
+
"""Persist the cache to disk (no-op when caching is disabled)."""
|
|
144
|
+
if self._cache is not None:
|
|
145
|
+
self._cache.save()
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Conversation memory — persistent multi-turn chat sessions.
|
|
2
|
+
|
|
3
|
+
Provides:
|
|
4
|
+
- **ConversationSession**: an ordered list of LLMMessage turns with metadata,
|
|
5
|
+
serialisable to / from disk.
|
|
6
|
+
- **SessionStore**: manages multiple named sessions under ``.codexa/sessions/``
|
|
7
|
+
with create, resume, list, and delete operations.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from semantic_code_intelligence.llm.provider import LLMMessage, MessageRole
|
|
20
|
+
from semantic_code_intelligence.utils.logging import get_logger
|
|
21
|
+
|
|
22
|
+
logger = get_logger("llm.conversation")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Conversation session
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ConversationSession:
|
|
31
|
+
"""A multi-turn conversation with an LLM."""
|
|
32
|
+
|
|
33
|
+
session_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
|
|
34
|
+
title: str = ""
|
|
35
|
+
messages: list[LLMMessage] = field(default_factory=list)
|
|
36
|
+
created_at: float = field(default_factory=time.time)
|
|
37
|
+
updated_at: float = field(default_factory=time.time)
|
|
38
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
39
|
+
|
|
40
|
+
# --- turn management ---
|
|
41
|
+
|
|
42
|
+
def add_message(self, role: MessageRole, content: str) -> LLMMessage:
|
|
43
|
+
"""Append a message to the conversation."""
|
|
44
|
+
msg = LLMMessage(role=role, content=content)
|
|
45
|
+
self.messages.append(msg)
|
|
46
|
+
self.updated_at = time.time()
|
|
47
|
+
return msg
|
|
48
|
+
|
|
49
|
+
def add_user(self, content: str) -> LLMMessage:
|
|
50
|
+
"""Append a user message to the session."""
|
|
51
|
+
return self.add_message(MessageRole.USER, content)
|
|
52
|
+
|
|
53
|
+
def add_assistant(self, content: str) -> LLMMessage:
|
|
54
|
+
"""Append an assistant message to the session."""
|
|
55
|
+
return self.add_message(MessageRole.ASSISTANT, content)
|
|
56
|
+
|
|
57
|
+
def add_system(self, content: str) -> LLMMessage:
|
|
58
|
+
"""Append a system message to the session."""
|
|
59
|
+
return self.add_message(MessageRole.SYSTEM, content)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def turn_count(self) -> int:
|
|
63
|
+
"""Number of user+assistant exchanges."""
|
|
64
|
+
return sum(1 for m in self.messages if m.role in (MessageRole.USER, MessageRole.ASSISTANT))
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def last_message(self) -> LLMMessage | None:
|
|
68
|
+
"""Return the most recent message, or None if empty."""
|
|
69
|
+
return self.messages[-1] if self.messages else None
|
|
70
|
+
|
|
71
|
+
def get_messages_for_llm(self, max_turns: int | None = None) -> list[LLMMessage]:
|
|
72
|
+
"""Return messages formatted for LLM consumption.
|
|
73
|
+
|
|
74
|
+
Always includes system messages. When *max_turns* is specified, keeps
|
|
75
|
+
the most recent user/assistant pairs to stay within context budget.
|
|
76
|
+
"""
|
|
77
|
+
system_msgs = [m for m in self.messages if m.role == MessageRole.SYSTEM]
|
|
78
|
+
conversation = [m for m in self.messages if m.role != MessageRole.SYSTEM]
|
|
79
|
+
|
|
80
|
+
if max_turns is not None and len(conversation) > max_turns * 2:
|
|
81
|
+
conversation = conversation[-(max_turns * 2):]
|
|
82
|
+
|
|
83
|
+
return system_msgs + conversation
|
|
84
|
+
|
|
85
|
+
# --- serialisation ---
|
|
86
|
+
|
|
87
|
+
def to_dict(self) -> dict[str, Any]:
|
|
88
|
+
"""Serialize the conversation session to a plain dictionary."""
|
|
89
|
+
return {
|
|
90
|
+
"session_id": self.session_id,
|
|
91
|
+
"title": self.title,
|
|
92
|
+
"messages": [{"role": m.role.value, "content": m.content} for m in self.messages],
|
|
93
|
+
"created_at": self.created_at,
|
|
94
|
+
"updated_at": self.updated_at,
|
|
95
|
+
"metadata": self.metadata,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def from_dict(cls, data: dict[str, Any]) -> ConversationSession:
|
|
100
|
+
"""Reconstruct a conversation session from a dictionary."""
|
|
101
|
+
messages = [
|
|
102
|
+
LLMMessage(role=MessageRole(m["role"]), content=m["content"])
|
|
103
|
+
for m in data.get("messages", [])
|
|
104
|
+
]
|
|
105
|
+
return cls(
|
|
106
|
+
session_id=data["session_id"],
|
|
107
|
+
title=data.get("title", ""),
|
|
108
|
+
messages=messages,
|
|
109
|
+
created_at=data.get("created_at", time.time()),
|
|
110
|
+
updated_at=data.get("updated_at", time.time()),
|
|
111
|
+
metadata=data.get("metadata", {}),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# Session store (persistent, file-backed)
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
SESSIONS_DIR = "sessions"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class SessionStore:
|
|
123
|
+
"""File-backed store for conversation sessions.
|
|
124
|
+
|
|
125
|
+
Sessions live under ``<project_root>/.codexa/sessions/<id>.json``.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def __init__(self, project_root: Path) -> None:
|
|
129
|
+
from semantic_code_intelligence.config.settings import AppConfig
|
|
130
|
+
|
|
131
|
+
self._dir = AppConfig.config_dir(project_root) / SESSIONS_DIR
|
|
132
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
|
|
134
|
+
def _session_path(self, session_id: str) -> Path:
|
|
135
|
+
# Sanitise session_id to prevent path traversal
|
|
136
|
+
safe_id = "".join(c for c in session_id if c.isalnum() or c in "-_")
|
|
137
|
+
return self._dir / f"{safe_id}.json"
|
|
138
|
+
|
|
139
|
+
def save(self, session: ConversationSession) -> Path:
|
|
140
|
+
"""Persist a session to disk."""
|
|
141
|
+
path = self._session_path(session.session_id)
|
|
142
|
+
path.write_text(json.dumps(session.to_dict(), indent=2), encoding="utf-8")
|
|
143
|
+
logger.debug("Saved session %s (%d messages)", session.session_id, len(session.messages))
|
|
144
|
+
return path
|
|
145
|
+
|
|
146
|
+
def load(self, session_id: str) -> ConversationSession | None:
|
|
147
|
+
"""Load a session by ID. Returns *None* if not found."""
|
|
148
|
+
path = self._session_path(session_id)
|
|
149
|
+
if not path.exists():
|
|
150
|
+
return None
|
|
151
|
+
try:
|
|
152
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
153
|
+
return ConversationSession.from_dict(data)
|
|
154
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
155
|
+
logger.warning("Corrupt session file %s", path)
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
def list_sessions(self) -> list[dict[str, Any]]:
|
|
159
|
+
"""Return a summary of all stored sessions (newest first)."""
|
|
160
|
+
sessions: list[dict[str, Any]] = []
|
|
161
|
+
for p in self._dir.glob("*.json"):
|
|
162
|
+
try:
|
|
163
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
164
|
+
sessions.append({
|
|
165
|
+
"session_id": data["session_id"],
|
|
166
|
+
"title": data.get("title", ""),
|
|
167
|
+
"turns": len(data.get("messages", [])),
|
|
168
|
+
"created_at": data.get("created_at", 0),
|
|
169
|
+
"updated_at": data.get("updated_at", 0),
|
|
170
|
+
})
|
|
171
|
+
except (json.JSONDecodeError, KeyError, TypeError):
|
|
172
|
+
continue
|
|
173
|
+
sessions.sort(key=lambda s: s["updated_at"], reverse=True)
|
|
174
|
+
return sessions
|
|
175
|
+
|
|
176
|
+
def delete(self, session_id: str) -> bool:
|
|
177
|
+
"""Delete a session file. Returns True if it existed."""
|
|
178
|
+
path = self._session_path(session_id)
|
|
179
|
+
if path.exists():
|
|
180
|
+
path.unlink()
|
|
181
|
+
return True
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
def get_or_create(self, session_id: str | None = None) -> ConversationSession:
|
|
185
|
+
"""Load an existing session or create a new one."""
|
|
186
|
+
if session_id:
|
|
187
|
+
existing = self.load(session_id)
|
|
188
|
+
if existing:
|
|
189
|
+
return existing
|
|
190
|
+
return ConversationSession()
|