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.
Files changed (189) hide show
  1. codexa-0.4.0.dist-info/METADATA +650 -0
  2. codexa-0.4.0.dist-info/RECORD +189 -0
  3. codexa-0.4.0.dist-info/WHEEL +5 -0
  4. codexa-0.4.0.dist-info/entry_points.txt +2 -0
  5. codexa-0.4.0.dist-info/licenses/LICENSE +21 -0
  6. codexa-0.4.0.dist-info/top_level.txt +1 -0
  7. semantic_code_intelligence/__init__.py +5 -0
  8. semantic_code_intelligence/analysis/__init__.py +21 -0
  9. semantic_code_intelligence/analysis/ai_features.py +351 -0
  10. semantic_code_intelligence/bridge/__init__.py +28 -0
  11. semantic_code_intelligence/bridge/context_provider.py +245 -0
  12. semantic_code_intelligence/bridge/protocol.py +167 -0
  13. semantic_code_intelligence/bridge/server.py +348 -0
  14. semantic_code_intelligence/bridge/vscode.py +271 -0
  15. semantic_code_intelligence/ci/__init__.py +13 -0
  16. semantic_code_intelligence/ci/hooks.py +98 -0
  17. semantic_code_intelligence/ci/hotspots.py +272 -0
  18. semantic_code_intelligence/ci/impact.py +246 -0
  19. semantic_code_intelligence/ci/metrics.py +591 -0
  20. semantic_code_intelligence/ci/pr.py +412 -0
  21. semantic_code_intelligence/ci/quality.py +557 -0
  22. semantic_code_intelligence/ci/templates.py +164 -0
  23. semantic_code_intelligence/ci/trace.py +224 -0
  24. semantic_code_intelligence/cli/__init__.py +0 -0
  25. semantic_code_intelligence/cli/commands/__init__.py +0 -0
  26. semantic_code_intelligence/cli/commands/ask_cmd.py +153 -0
  27. semantic_code_intelligence/cli/commands/benchmark_cmd.py +303 -0
  28. semantic_code_intelligence/cli/commands/chat_cmd.py +252 -0
  29. semantic_code_intelligence/cli/commands/ci_gen_cmd.py +74 -0
  30. semantic_code_intelligence/cli/commands/context_cmd.py +120 -0
  31. semantic_code_intelligence/cli/commands/cross_refactor_cmd.py +113 -0
  32. semantic_code_intelligence/cli/commands/deps_cmd.py +91 -0
  33. semantic_code_intelligence/cli/commands/docs_cmd.py +101 -0
  34. semantic_code_intelligence/cli/commands/doctor_cmd.py +147 -0
  35. semantic_code_intelligence/cli/commands/evolve_cmd.py +171 -0
  36. semantic_code_intelligence/cli/commands/explain_cmd.py +112 -0
  37. semantic_code_intelligence/cli/commands/gate_cmd.py +135 -0
  38. semantic_code_intelligence/cli/commands/grep_cmd.py +234 -0
  39. semantic_code_intelligence/cli/commands/hotspots_cmd.py +119 -0
  40. semantic_code_intelligence/cli/commands/impact_cmd.py +131 -0
  41. semantic_code_intelligence/cli/commands/index_cmd.py +138 -0
  42. semantic_code_intelligence/cli/commands/init_cmd.py +152 -0
  43. semantic_code_intelligence/cli/commands/investigate_cmd.py +163 -0
  44. semantic_code_intelligence/cli/commands/languages_cmd.py +101 -0
  45. semantic_code_intelligence/cli/commands/lsp_cmd.py +49 -0
  46. semantic_code_intelligence/cli/commands/mcp_cmd.py +50 -0
  47. semantic_code_intelligence/cli/commands/metrics_cmd.py +264 -0
  48. semantic_code_intelligence/cli/commands/models_cmd.py +157 -0
  49. semantic_code_intelligence/cli/commands/plugin_cmd.py +275 -0
  50. semantic_code_intelligence/cli/commands/pr_summary_cmd.py +178 -0
  51. semantic_code_intelligence/cli/commands/quality_cmd.py +208 -0
  52. semantic_code_intelligence/cli/commands/refactor_cmd.py +103 -0
  53. semantic_code_intelligence/cli/commands/review_cmd.py +88 -0
  54. semantic_code_intelligence/cli/commands/search_cmd.py +236 -0
  55. semantic_code_intelligence/cli/commands/serve_cmd.py +117 -0
  56. semantic_code_intelligence/cli/commands/suggest_cmd.py +100 -0
  57. semantic_code_intelligence/cli/commands/summary_cmd.py +78 -0
  58. semantic_code_intelligence/cli/commands/tool_cmd.py +282 -0
  59. semantic_code_intelligence/cli/commands/trace_cmd.py +123 -0
  60. semantic_code_intelligence/cli/commands/tui_cmd.py +58 -0
  61. semantic_code_intelligence/cli/commands/viz_cmd.py +127 -0
  62. semantic_code_intelligence/cli/commands/watch_cmd.py +72 -0
  63. semantic_code_intelligence/cli/commands/web_cmd.py +61 -0
  64. semantic_code_intelligence/cli/commands/workspace_cmd.py +250 -0
  65. semantic_code_intelligence/cli/main.py +65 -0
  66. semantic_code_intelligence/cli/router.py +92 -0
  67. semantic_code_intelligence/config/__init__.py +0 -0
  68. semantic_code_intelligence/config/settings.py +260 -0
  69. semantic_code_intelligence/context/__init__.py +19 -0
  70. semantic_code_intelligence/context/engine.py +429 -0
  71. semantic_code_intelligence/context/memory.py +253 -0
  72. semantic_code_intelligence/daemon/__init__.py +1 -0
  73. semantic_code_intelligence/daemon/watcher.py +515 -0
  74. semantic_code_intelligence/docs/__init__.py +1080 -0
  75. semantic_code_intelligence/embeddings/__init__.py +0 -0
  76. semantic_code_intelligence/embeddings/enhanced.py +131 -0
  77. semantic_code_intelligence/embeddings/generator.py +149 -0
  78. semantic_code_intelligence/embeddings/model_registry.py +100 -0
  79. semantic_code_intelligence/evolution/__init__.py +1 -0
  80. semantic_code_intelligence/evolution/budget_guard.py +111 -0
  81. semantic_code_intelligence/evolution/commit_manager.py +88 -0
  82. semantic_code_intelligence/evolution/context_builder.py +131 -0
  83. semantic_code_intelligence/evolution/engine.py +249 -0
  84. semantic_code_intelligence/evolution/patch_generator.py +229 -0
  85. semantic_code_intelligence/evolution/task_selector.py +214 -0
  86. semantic_code_intelligence/evolution/test_runner.py +111 -0
  87. semantic_code_intelligence/indexing/__init__.py +0 -0
  88. semantic_code_intelligence/indexing/chunker.py +174 -0
  89. semantic_code_intelligence/indexing/parallel.py +86 -0
  90. semantic_code_intelligence/indexing/scanner.py +146 -0
  91. semantic_code_intelligence/indexing/semantic_chunker.py +337 -0
  92. semantic_code_intelligence/llm/__init__.py +62 -0
  93. semantic_code_intelligence/llm/cache.py +219 -0
  94. semantic_code_intelligence/llm/cached_provider.py +145 -0
  95. semantic_code_intelligence/llm/conversation.py +190 -0
  96. semantic_code_intelligence/llm/cross_refactor.py +272 -0
  97. semantic_code_intelligence/llm/investigation.py +274 -0
  98. semantic_code_intelligence/llm/mock_provider.py +77 -0
  99. semantic_code_intelligence/llm/ollama_provider.py +122 -0
  100. semantic_code_intelligence/llm/openai_provider.py +100 -0
  101. semantic_code_intelligence/llm/provider.py +92 -0
  102. semantic_code_intelligence/llm/rate_limiter.py +164 -0
  103. semantic_code_intelligence/llm/reasoning.py +438 -0
  104. semantic_code_intelligence/llm/safety.py +110 -0
  105. semantic_code_intelligence/llm/streaming.py +251 -0
  106. semantic_code_intelligence/lsp/__init__.py +609 -0
  107. semantic_code_intelligence/mcp/__init__.py +393 -0
  108. semantic_code_intelligence/parsing/__init__.py +19 -0
  109. semantic_code_intelligence/parsing/parser.py +375 -0
  110. semantic_code_intelligence/plugins/__init__.py +255 -0
  111. semantic_code_intelligence/plugins/examples/__init__.py +1 -0
  112. semantic_code_intelligence/plugins/examples/code_quality.py +73 -0
  113. semantic_code_intelligence/plugins/examples/search_annotator.py +56 -0
  114. semantic_code_intelligence/scalability/__init__.py +205 -0
  115. semantic_code_intelligence/search/__init__.py +0 -0
  116. semantic_code_intelligence/search/formatter.py +123 -0
  117. semantic_code_intelligence/search/grep.py +361 -0
  118. semantic_code_intelligence/search/hybrid_search.py +170 -0
  119. semantic_code_intelligence/search/keyword_search.py +311 -0
  120. semantic_code_intelligence/search/section_expander.py +103 -0
  121. semantic_code_intelligence/services/__init__.py +0 -0
  122. semantic_code_intelligence/services/indexing_service.py +630 -0
  123. semantic_code_intelligence/services/search_service.py +269 -0
  124. semantic_code_intelligence/storage/__init__.py +0 -0
  125. semantic_code_intelligence/storage/chunk_hash_store.py +86 -0
  126. semantic_code_intelligence/storage/hash_store.py +66 -0
  127. semantic_code_intelligence/storage/index_manifest.py +85 -0
  128. semantic_code_intelligence/storage/index_stats.py +138 -0
  129. semantic_code_intelligence/storage/query_history.py +160 -0
  130. semantic_code_intelligence/storage/symbol_registry.py +209 -0
  131. semantic_code_intelligence/storage/vector_store.py +297 -0
  132. semantic_code_intelligence/tests/__init__.py +0 -0
  133. semantic_code_intelligence/tests/test_ai_features.py +351 -0
  134. semantic_code_intelligence/tests/test_chunker.py +119 -0
  135. semantic_code_intelligence/tests/test_cli.py +188 -0
  136. semantic_code_intelligence/tests/test_config.py +154 -0
  137. semantic_code_intelligence/tests/test_context.py +381 -0
  138. semantic_code_intelligence/tests/test_embeddings.py +73 -0
  139. semantic_code_intelligence/tests/test_endtoend.py +1142 -0
  140. semantic_code_intelligence/tests/test_enhanced_embeddings.py +92 -0
  141. semantic_code_intelligence/tests/test_hash_store.py +79 -0
  142. semantic_code_intelligence/tests/test_logging.py +55 -0
  143. semantic_code_intelligence/tests/test_new_cli.py +138 -0
  144. semantic_code_intelligence/tests/test_parser.py +495 -0
  145. semantic_code_intelligence/tests/test_phase10.py +355 -0
  146. semantic_code_intelligence/tests/test_phase11.py +593 -0
  147. semantic_code_intelligence/tests/test_phase12.py +375 -0
  148. semantic_code_intelligence/tests/test_phase13.py +663 -0
  149. semantic_code_intelligence/tests/test_phase14.py +568 -0
  150. semantic_code_intelligence/tests/test_phase15.py +814 -0
  151. semantic_code_intelligence/tests/test_phase16.py +792 -0
  152. semantic_code_intelligence/tests/test_phase17.py +815 -0
  153. semantic_code_intelligence/tests/test_phase18.py +934 -0
  154. semantic_code_intelligence/tests/test_phase19.py +986 -0
  155. semantic_code_intelligence/tests/test_phase20.py +2753 -0
  156. semantic_code_intelligence/tests/test_phase20b.py +2058 -0
  157. semantic_code_intelligence/tests/test_phase20c.py +962 -0
  158. semantic_code_intelligence/tests/test_phase21.py +428 -0
  159. semantic_code_intelligence/tests/test_phase22.py +799 -0
  160. semantic_code_intelligence/tests/test_phase23.py +783 -0
  161. semantic_code_intelligence/tests/test_phase24.py +715 -0
  162. semantic_code_intelligence/tests/test_phase25.py +496 -0
  163. semantic_code_intelligence/tests/test_phase26.py +251 -0
  164. semantic_code_intelligence/tests/test_phase27.py +531 -0
  165. semantic_code_intelligence/tests/test_phase8.py +592 -0
  166. semantic_code_intelligence/tests/test_phase9.py +643 -0
  167. semantic_code_intelligence/tests/test_plugins.py +293 -0
  168. semantic_code_intelligence/tests/test_priority_features.py +727 -0
  169. semantic_code_intelligence/tests/test_router.py +41 -0
  170. semantic_code_intelligence/tests/test_scalability.py +138 -0
  171. semantic_code_intelligence/tests/test_scanner.py +125 -0
  172. semantic_code_intelligence/tests/test_search.py +160 -0
  173. semantic_code_intelligence/tests/test_semantic_chunker.py +255 -0
  174. semantic_code_intelligence/tests/test_tools.py +182 -0
  175. semantic_code_intelligence/tests/test_vector_store.py +151 -0
  176. semantic_code_intelligence/tests/test_watcher.py +211 -0
  177. semantic_code_intelligence/tools/__init__.py +442 -0
  178. semantic_code_intelligence/tools/executor.py +232 -0
  179. semantic_code_intelligence/tools/protocol.py +200 -0
  180. semantic_code_intelligence/tui/__init__.py +454 -0
  181. semantic_code_intelligence/utils/__init__.py +0 -0
  182. semantic_code_intelligence/utils/logging.py +112 -0
  183. semantic_code_intelligence/version.py +3 -0
  184. semantic_code_intelligence/web/__init__.py +11 -0
  185. semantic_code_intelligence/web/api.py +289 -0
  186. semantic_code_intelligence/web/server.py +397 -0
  187. semantic_code_intelligence/web/ui.py +659 -0
  188. semantic_code_intelligence/web/visualize.py +226 -0
  189. 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()