code-context-control 2.28.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.
- cli/__init__.py +1 -0
- cli/_hook_utils.py +99 -0
- cli/c3.py +6152 -0
- cli/commands/__init__.py +1 -0
- cli/commands/common.py +312 -0
- cli/commands/parser.py +286 -0
- cli/docs.html +3178 -0
- cli/edits.html +878 -0
- cli/hook_auto_snapshot.py +142 -0
- cli/hook_c3_signal.py +61 -0
- cli/hook_c3read.py +116 -0
- cli/hook_edit_ledger.py +213 -0
- cli/hook_edit_unlock.py +170 -0
- cli/hook_filter.py +130 -0
- cli/hook_ghost_files.py +238 -0
- cli/hook_pretool_enforce.py +334 -0
- cli/hook_read.py +200 -0
- cli/hook_session_stats.py +62 -0
- cli/hook_terse_advisor.py +190 -0
- cli/hub.html +3764 -0
- cli/hub_server.py +1619 -0
- cli/mcp_proxy.py +428 -0
- cli/mcp_server.py +660 -0
- cli/server.py +2985 -0
- cli/tools/__init__.py +4 -0
- cli/tools/_helpers.py +65 -0
- cli/tools/agent.py +1165 -0
- cli/tools/compress.py +215 -0
- cli/tools/delegate.py +1184 -0
- cli/tools/edit.py +313 -0
- cli/tools/edits.py +118 -0
- cli/tools/filter.py +285 -0
- cli/tools/impact.py +163 -0
- cli/tools/memory.py +469 -0
- cli/tools/read.py +224 -0
- cli/tools/search.py +337 -0
- cli/tools/session.py +95 -0
- cli/tools/shell.py +193 -0
- cli/tools/status.py +306 -0
- cli/tools/validate.py +310 -0
- cli/ui/api.js +36 -0
- cli/ui/app.js +207 -0
- cli/ui/components/chat.js +758 -0
- cli/ui/components/dashboard.js +689 -0
- cli/ui/components/edits.js +220 -0
- cli/ui/components/instructions.js +481 -0
- cli/ui/components/memory.js +626 -0
- cli/ui/components/sessions.js +606 -0
- cli/ui/components/settings.js +1404 -0
- cli/ui/components/sidebar.js +156 -0
- cli/ui/icons.js +51 -0
- cli/ui/shared.js +119 -0
- cli/ui/theme.js +22 -0
- cli/ui.html +168 -0
- cli/ui_legacy.html +6797 -0
- cli/ui_nano.html +503 -0
- code_context_control-2.28.0.dist-info/METADATA +248 -0
- code_context_control-2.28.0.dist-info/RECORD +150 -0
- code_context_control-2.28.0.dist-info/WHEEL +5 -0
- code_context_control-2.28.0.dist-info/entry_points.txt +4 -0
- code_context_control-2.28.0.dist-info/licenses/LICENSE +201 -0
- code_context_control-2.28.0.dist-info/top_level.txt +5 -0
- core/__init__.py +75 -0
- core/config.py +269 -0
- core/ide.py +188 -0
- oracle/__init__.py +1 -0
- oracle/config.py +75 -0
- oracle/oracle.html +3900 -0
- oracle/oracle_server.py +663 -0
- oracle/services/__init__.py +1 -0
- oracle/services/c3_bridge.py +210 -0
- oracle/services/chat_engine.py +1103 -0
- oracle/services/chat_store.py +155 -0
- oracle/services/cross_memory.py +154 -0
- oracle/services/federated_graph.py +463 -0
- oracle/services/health_checker.py +117 -0
- oracle/services/insight_engine.py +307 -0
- oracle/services/memory_reader.py +106 -0
- oracle/services/memory_writer.py +182 -0
- oracle/services/ollama_bridge.py +332 -0
- oracle/services/project_scanner.py +87 -0
- oracle/services/review_agent.py +206 -0
- services/__init__.py +1 -0
- services/activity_log.py +93 -0
- services/agent_base.py +124 -0
- services/agents.py +1529 -0
- services/auto_memory.py +407 -0
- services/bench/__init__.py +6 -0
- services/bench/external/__init__.py +29 -0
- services/bench/external/aider_polyglot.py +405 -0
- services/bench/external/swe_bench.py +485 -0
- services/benchmark_dashboard.py +596 -0
- services/claude_md.py +785 -0
- services/compressor.py +592 -0
- services/context_snapshot.py +356 -0
- services/conversation_store.py +870 -0
- services/doc_index.py +537 -0
- services/e2e_benchmark.py +2884 -0
- services/e2e_evaluator.py +396 -0
- services/e2e_tasks.py +743 -0
- services/edit_ledger.py +459 -0
- services/embedding_index.py +341 -0
- services/error_reporting.py +123 -0
- services/file_memory.py +734 -0
- services/hub_service.py +585 -0
- services/indexer.py +712 -0
- services/memory.py +318 -0
- services/memory_consolidator.py +538 -0
- services/memory_graph.py +382 -0
- services/memory_grounder.py +304 -0
- services/memory_scorer.py +246 -0
- services/metrics.py +86 -0
- services/notifications.py +209 -0
- services/ollama_client.py +201 -0
- services/output_filter.py +488 -0
- services/parser.py +1238 -0
- services/project_manager.py +579 -0
- services/protocol.py +306 -0
- services/proxy_state.py +152 -0
- services/retrieval_broker.py +129 -0
- services/router.py +414 -0
- services/runtime.py +326 -0
- services/session_benchmark.py +1945 -0
- services/session_manager.py +1026 -0
- services/session_preloader.py +251 -0
- services/text_index.py +90 -0
- services/tool_classifier.py +176 -0
- services/transcript_index.py +340 -0
- services/validation_cache.py +155 -0
- services/vector_store.py +299 -0
- services/version_tracker.py +271 -0
- services/watcher.py +192 -0
- tui/__init__.py +0 -0
- tui/backend.py +59 -0
- tui/main.py +145 -0
- tui/screens/__init__.py +1 -0
- tui/screens/benchmark_view.py +109 -0
- tui/screens/claudemd_view.py +46 -0
- tui/screens/compress_view.py +52 -0
- tui/screens/index_view.py +74 -0
- tui/screens/init_view.py +82 -0
- tui/screens/mcp_view.py +73 -0
- tui/screens/optimize_view.py +41 -0
- tui/screens/pipe_view.py +46 -0
- tui/screens/projects_view.py +355 -0
- tui/screens/search_view.py +55 -0
- tui/screens/session_view.py +143 -0
- tui/screens/stats.py +158 -0
- tui/screens/ui_view.py +54 -0
- tui/theme.tcss +335 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Incremental Embedding Index for semantic code search.
|
|
3
|
+
|
|
4
|
+
Embeds code chunks from CodeIndex into a chromadb collection using Ollama
|
|
5
|
+
embeddings. Tracks file content hashes to only re-embed changed files.
|
|
6
|
+
Falls back gracefully when Ollama or chromadb are unavailable.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import threading
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger("c3.embedding_index")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EmbeddingIndex:
|
|
19
|
+
"""Semantic code search via embeddings over CodeIndex chunks."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
project_path: str,
|
|
24
|
+
ollama_client,
|
|
25
|
+
embed_model: str = "nomic-embed-text",
|
|
26
|
+
batch_size: int = 32,
|
|
27
|
+
):
|
|
28
|
+
self.project_path = Path(project_path)
|
|
29
|
+
self.ollama = ollama_client
|
|
30
|
+
self.embed_model = embed_model
|
|
31
|
+
self.batch_size = batch_size
|
|
32
|
+
|
|
33
|
+
self._index_dir = self.project_path / ".c3" / "embeddings"
|
|
34
|
+
self._index_dir.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
self._hash_file = self._index_dir / "file_hashes.json"
|
|
36
|
+
|
|
37
|
+
self._chroma_client = None
|
|
38
|
+
self._collection = None
|
|
39
|
+
self._available = False
|
|
40
|
+
self._ollama_ok = False
|
|
41
|
+
self._file_hashes: dict[str, str] = {} # doc_id -> content hash
|
|
42
|
+
self._lock = threading.Lock()
|
|
43
|
+
self._chunk_map: dict[str, dict] = {} # chunk_id -> metadata
|
|
44
|
+
|
|
45
|
+
self._init_backends()
|
|
46
|
+
self._load_hashes()
|
|
47
|
+
|
|
48
|
+
# ── Backend init ──────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
def _init_backends(self):
|
|
51
|
+
"""Initialize chromadb collection and check Ollama."""
|
|
52
|
+
try:
|
|
53
|
+
import chromadb
|
|
54
|
+
from chromadb.config import Settings
|
|
55
|
+
|
|
56
|
+
persist_dir = str(self._index_dir / "chromadb")
|
|
57
|
+
Path(persist_dir).mkdir(parents=True, exist_ok=True)
|
|
58
|
+
self._chroma_client = chromadb.PersistentClient(
|
|
59
|
+
path=persist_dir,
|
|
60
|
+
settings=Settings(anonymized_telemetry=False),
|
|
61
|
+
)
|
|
62
|
+
self._collection = self._chroma_client.get_or_create_collection(
|
|
63
|
+
name="code_embeddings",
|
|
64
|
+
metadata={"hnsw:space": "cosine"},
|
|
65
|
+
)
|
|
66
|
+
self._available = True
|
|
67
|
+
except Exception as e:
|
|
68
|
+
log.debug("chromadb unavailable for embedding index: %s", e)
|
|
69
|
+
self._available = False
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
self._ollama_ok = (
|
|
73
|
+
self.ollama.is_available(timeout=2)
|
|
74
|
+
and self.ollama.has_model(self.embed_model)
|
|
75
|
+
)
|
|
76
|
+
except Exception:
|
|
77
|
+
self._ollama_ok = False
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def ready(self) -> bool:
|
|
81
|
+
"""True when both chromadb and Ollama embeddings are available."""
|
|
82
|
+
return self._available and self._ollama_ok
|
|
83
|
+
|
|
84
|
+
# ── Hash tracking ─────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
def _load_hashes(self):
|
|
87
|
+
"""Load persisted file content hashes."""
|
|
88
|
+
if self._hash_file.exists():
|
|
89
|
+
try:
|
|
90
|
+
with open(self._hash_file, encoding='utf-8') as f:
|
|
91
|
+
self._file_hashes = json.load(f)
|
|
92
|
+
except Exception:
|
|
93
|
+
self._file_hashes = {}
|
|
94
|
+
|
|
95
|
+
def _save_hashes(self):
|
|
96
|
+
"""Persist file content hashes."""
|
|
97
|
+
try:
|
|
98
|
+
with open(self._hash_file, "w", encoding='utf-8') as f:
|
|
99
|
+
json.dump(self._file_hashes, f)
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def _content_hash(content: str) -> str:
|
|
105
|
+
return hashlib.sha256(content.encode(errors="replace")).hexdigest()[:16]
|
|
106
|
+
|
|
107
|
+
# ── Build / Update ────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
def build(self, code_index, force: bool = False) -> dict:
|
|
110
|
+
"""Build or incrementally update the embedding index from CodeIndex chunks.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
code_index: A CodeIndex instance with populated chunks/documents.
|
|
114
|
+
force: If True, re-embed all files regardless of hash.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Stats dict with files_processed, chunks_embedded, chunks_skipped, etc.
|
|
118
|
+
"""
|
|
119
|
+
if not self.ready:
|
|
120
|
+
return {"error": "Embedding backends unavailable", "available": False}
|
|
121
|
+
|
|
122
|
+
if not code_index.chunks:
|
|
123
|
+
code_index._load_index()
|
|
124
|
+
if not code_index.chunks:
|
|
125
|
+
return {"error": "No code index chunks found. Build code index first."}
|
|
126
|
+
|
|
127
|
+
# Group chunks by doc_id (file)
|
|
128
|
+
chunks_by_file: dict[str, list[tuple[str, dict]]] = {}
|
|
129
|
+
for chunk_id, chunk in code_index.chunks.items():
|
|
130
|
+
doc_id = chunk.get("doc_id", "")
|
|
131
|
+
if doc_id:
|
|
132
|
+
chunks_by_file.setdefault(doc_id, []).append((chunk_id, chunk))
|
|
133
|
+
|
|
134
|
+
files_processed = 0
|
|
135
|
+
chunks_embedded = 0
|
|
136
|
+
chunks_skipped = 0
|
|
137
|
+
files_skipped = 0
|
|
138
|
+
errors = 0
|
|
139
|
+
stale_ids = []
|
|
140
|
+
|
|
141
|
+
with self._lock:
|
|
142
|
+
# Detect deleted files — remove their embeddings
|
|
143
|
+
indexed_files = set(self._file_hashes.keys())
|
|
144
|
+
current_files = set(chunks_by_file.keys())
|
|
145
|
+
for removed_file in indexed_files - current_files:
|
|
146
|
+
self._remove_file_chunks(removed_file)
|
|
147
|
+
del self._file_hashes[removed_file]
|
|
148
|
+
|
|
149
|
+
for doc_id, file_chunks in chunks_by_file.items():
|
|
150
|
+
# Check if file content changed
|
|
151
|
+
content = "".join(c.get("content", "") for _, c in file_chunks)
|
|
152
|
+
new_hash = self._content_hash(content)
|
|
153
|
+
|
|
154
|
+
if not force and self._file_hashes.get(doc_id) == new_hash:
|
|
155
|
+
files_skipped += 1
|
|
156
|
+
chunks_skipped += len(file_chunks)
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
# Remove old chunks for this file before re-embedding
|
|
160
|
+
self._remove_file_chunks(doc_id)
|
|
161
|
+
|
|
162
|
+
# Batch embed
|
|
163
|
+
batch_ids = []
|
|
164
|
+
batch_texts = []
|
|
165
|
+
batch_metas = []
|
|
166
|
+
for chunk_id, chunk in file_chunks:
|
|
167
|
+
text = chunk.get("content", "").strip()
|
|
168
|
+
if not text or len(text) < 20:
|
|
169
|
+
chunks_skipped += 1
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
# Prefix with file path + symbol for richer embeddings
|
|
173
|
+
name = chunk.get("name", "")
|
|
174
|
+
prefix = f"File: {doc_id}"
|
|
175
|
+
if name:
|
|
176
|
+
prefix += f" | {chunk.get('type', 'symbol')}: {name}"
|
|
177
|
+
embed_text = f"{prefix}\n{text}"
|
|
178
|
+
|
|
179
|
+
batch_ids.append(chunk_id)
|
|
180
|
+
batch_texts.append(embed_text)
|
|
181
|
+
batch_metas.append({
|
|
182
|
+
"doc_id": doc_id,
|
|
183
|
+
"name": name or "",
|
|
184
|
+
"type": chunk.get("type", "chunk"),
|
|
185
|
+
"line_start": chunk.get("line_start", 0),
|
|
186
|
+
"line_end": chunk.get("line_end", 0),
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
if len(batch_ids) >= self.batch_size:
|
|
190
|
+
ok = self._embed_batch(batch_ids, batch_texts, batch_metas)
|
|
191
|
+
if ok:
|
|
192
|
+
chunks_embedded += len(batch_ids)
|
|
193
|
+
else:
|
|
194
|
+
errors += len(batch_ids)
|
|
195
|
+
batch_ids, batch_texts, batch_metas = [], [], []
|
|
196
|
+
|
|
197
|
+
# Flush remaining batch
|
|
198
|
+
if batch_ids:
|
|
199
|
+
ok = self._embed_batch(batch_ids, batch_texts, batch_metas)
|
|
200
|
+
if ok:
|
|
201
|
+
chunks_embedded += len(batch_ids)
|
|
202
|
+
else:
|
|
203
|
+
errors += len(batch_ids)
|
|
204
|
+
|
|
205
|
+
self._file_hashes[doc_id] = new_hash
|
|
206
|
+
files_processed += 1
|
|
207
|
+
|
|
208
|
+
self._save_hashes()
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
"files_processed": files_processed,
|
|
212
|
+
"files_skipped": files_skipped,
|
|
213
|
+
"chunks_embedded": chunks_embedded,
|
|
214
|
+
"chunks_skipped": chunks_skipped,
|
|
215
|
+
"errors": errors,
|
|
216
|
+
"total_embedded": self._collection.count() if self._collection else 0,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
def _embed_batch(self, ids: list, texts: list, metas: list) -> bool:
|
|
220
|
+
"""Embed and store a batch of chunks. Returns True on success."""
|
|
221
|
+
try:
|
|
222
|
+
embeddings = self.ollama.embed_batch(texts, model=self.embed_model)
|
|
223
|
+
if not embeddings or len(embeddings) != len(ids):
|
|
224
|
+
return False
|
|
225
|
+
self._collection.upsert(
|
|
226
|
+
ids=ids,
|
|
227
|
+
embeddings=embeddings,
|
|
228
|
+
documents=texts,
|
|
229
|
+
metadatas=metas,
|
|
230
|
+
)
|
|
231
|
+
return True
|
|
232
|
+
except Exception as e:
|
|
233
|
+
log.debug("Embedding batch failed: %s", e)
|
|
234
|
+
return False
|
|
235
|
+
|
|
236
|
+
def _remove_file_chunks(self, doc_id: str):
|
|
237
|
+
"""Remove all embedded chunks belonging to a file."""
|
|
238
|
+
if not self._collection:
|
|
239
|
+
return
|
|
240
|
+
try:
|
|
241
|
+
self._collection.delete(where={"doc_id": doc_id})
|
|
242
|
+
except Exception:
|
|
243
|
+
# Some chromadb versions don't support where-delete well;
|
|
244
|
+
# fall back to getting IDs first
|
|
245
|
+
try:
|
|
246
|
+
results = self._collection.get(where={"doc_id": doc_id})
|
|
247
|
+
if results and results.get("ids"):
|
|
248
|
+
self._collection.delete(ids=results["ids"])
|
|
249
|
+
except Exception:
|
|
250
|
+
pass
|
|
251
|
+
|
|
252
|
+
# ── Search ────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
def search(
|
|
255
|
+
self,
|
|
256
|
+
query: str,
|
|
257
|
+
top_k: int = 5,
|
|
258
|
+
max_tokens: int = 2000,
|
|
259
|
+
) -> list[dict]:
|
|
260
|
+
"""Semantic search over embedded code chunks.
|
|
261
|
+
|
|
262
|
+
Returns list of dicts with: file, lines, name, type, content, score, tokens.
|
|
263
|
+
"""
|
|
264
|
+
if not self.ready or not self._collection or self._collection.count() == 0:
|
|
265
|
+
return []
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
query_embedding = self.ollama.embed(query, model=self.embed_model)
|
|
269
|
+
if not query_embedding:
|
|
270
|
+
return []
|
|
271
|
+
|
|
272
|
+
results = self._collection.query(
|
|
273
|
+
query_embeddings=[query_embedding],
|
|
274
|
+
n_results=min(top_k * 2, self._collection.count()),
|
|
275
|
+
include=["documents", "metadatas", "distances"],
|
|
276
|
+
)
|
|
277
|
+
except Exception as e:
|
|
278
|
+
log.debug("Semantic search failed: %s", e)
|
|
279
|
+
return []
|
|
280
|
+
|
|
281
|
+
if not results or not results.get("ids") or not results["ids"][0]:
|
|
282
|
+
return []
|
|
283
|
+
|
|
284
|
+
ids = results["ids"][0]
|
|
285
|
+
documents = results["documents"][0] if results.get("documents") else []
|
|
286
|
+
metadatas = results["metadatas"][0] if results.get("metadatas") else []
|
|
287
|
+
distances = results["distances"][0] if results.get("distances") else []
|
|
288
|
+
|
|
289
|
+
from core import count_tokens
|
|
290
|
+
|
|
291
|
+
output = []
|
|
292
|
+
total_tokens = 0
|
|
293
|
+
for i, chunk_id in enumerate(ids):
|
|
294
|
+
meta = metadatas[i] if i < len(metadatas) else {}
|
|
295
|
+
doc = documents[i] if i < len(documents) else ""
|
|
296
|
+
dist = distances[i] if i < len(distances) else 1.0
|
|
297
|
+
|
|
298
|
+
# chromadb cosine distance: 0 = identical, 2 = opposite
|
|
299
|
+
score = max(0.0, 1.0 - dist)
|
|
300
|
+
|
|
301
|
+
# Strip the prefix we added during embedding
|
|
302
|
+
content = doc
|
|
303
|
+
if "\n" in content:
|
|
304
|
+
content = content.split("\n", 1)[1]
|
|
305
|
+
|
|
306
|
+
tok = count_tokens(content)
|
|
307
|
+
if total_tokens + tok > max_tokens and output:
|
|
308
|
+
break
|
|
309
|
+
|
|
310
|
+
line_start = meta.get("line_start", 0)
|
|
311
|
+
line_end = meta.get("line_end", 0)
|
|
312
|
+
lines_str = f"{line_start}-{line_end}" if line_start else "?"
|
|
313
|
+
|
|
314
|
+
output.append({
|
|
315
|
+
"file": meta.get("doc_id", "?"),
|
|
316
|
+
"lines": lines_str,
|
|
317
|
+
"name": meta.get("name", ""),
|
|
318
|
+
"type": meta.get("type", "chunk"),
|
|
319
|
+
"content": content,
|
|
320
|
+
"score": round(score, 4),
|
|
321
|
+
"tokens": tok,
|
|
322
|
+
})
|
|
323
|
+
total_tokens += tok
|
|
324
|
+
|
|
325
|
+
if len(output) >= top_k:
|
|
326
|
+
break
|
|
327
|
+
|
|
328
|
+
return output
|
|
329
|
+
|
|
330
|
+
# ── Stats ─────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
def get_stats(self) -> dict:
|
|
333
|
+
count = self._collection.count() if self._collection else 0
|
|
334
|
+
return {
|
|
335
|
+
"ready": self.ready,
|
|
336
|
+
"chromadb_available": self._available,
|
|
337
|
+
"ollama_available": self._ollama_ok,
|
|
338
|
+
"embed_model": self.embed_model,
|
|
339
|
+
"total_embedded_chunks": count,
|
|
340
|
+
"files_tracked": len(self._file_hashes),
|
|
341
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Opt-in crash + error reporting via Sentry.
|
|
2
|
+
|
|
3
|
+
Off by default. Enabled only when ALL of the following are true:
|
|
4
|
+
|
|
5
|
+
1. The ``sentry-sdk`` package is installed (pulled in by the optional
|
|
6
|
+
``code-context-control[telemetry]`` extra).
|
|
7
|
+
2. ``SENTRY_DSN`` is set in the environment.
|
|
8
|
+
3. The user has opted in by setting ``C3_TELEMETRY_OPT_IN=1`` in the
|
|
9
|
+
environment OR by creating ``~/.c3/telemetry.json`` with
|
|
10
|
+
``{"opt_in": true}``.
|
|
11
|
+
|
|
12
|
+
When enabled, only unhandled exceptions and explicit ``capture_error``
|
|
13
|
+
calls are transmitted. We strip query strings, file paths, and any
|
|
14
|
+
``args``/``kwargs`` payloads via a ``before_send`` hook to avoid
|
|
15
|
+
leaking source code or prompts. No performance / tracing data is sent.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
_INITIALIZED = False
|
|
25
|
+
_TELEMETRY_FILE = Path.home() / ".c3" / "telemetry.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _user_opted_in() -> bool:
|
|
29
|
+
if os.environ.get("C3_TELEMETRY_OPT_IN") == "1":
|
|
30
|
+
return True
|
|
31
|
+
try:
|
|
32
|
+
if _TELEMETRY_FILE.exists():
|
|
33
|
+
data = json.loads(_TELEMETRY_FILE.read_text(encoding="utf-8"))
|
|
34
|
+
return bool(data.get("opt_in"))
|
|
35
|
+
except Exception:
|
|
36
|
+
return False
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _scrub_event(event: dict[str, Any], hint: dict[str, Any]) -> dict[str, Any] | None:
|
|
41
|
+
"""Strip potentially-sensitive payloads from Sentry events."""
|
|
42
|
+
try:
|
|
43
|
+
# Remove request body / query string if present
|
|
44
|
+
request = event.get("request") or {}
|
|
45
|
+
request.pop("data", None)
|
|
46
|
+
request.pop("query_string", None)
|
|
47
|
+
request.pop("cookies", None)
|
|
48
|
+
request.pop("headers", None)
|
|
49
|
+
if request:
|
|
50
|
+
event["request"] = request
|
|
51
|
+
|
|
52
|
+
# Remove local variables from stack frames (often contain file content,
|
|
53
|
+
# prompts, or model output).
|
|
54
|
+
for thread in event.get("threads", {}).get("values", []) or []:
|
|
55
|
+
for frame in thread.get("stacktrace", {}).get("frames", []) or []:
|
|
56
|
+
frame.pop("vars", None)
|
|
57
|
+
for exc in event.get("exception", {}).get("values", []) or []:
|
|
58
|
+
for frame in exc.get("stacktrace", {}).get("frames", []) or []:
|
|
59
|
+
frame.pop("vars", None)
|
|
60
|
+
|
|
61
|
+
# Strip extra/contexts payloads
|
|
62
|
+
event.pop("extra", None)
|
|
63
|
+
event["contexts"] = {
|
|
64
|
+
k: v for k, v in (event.get("contexts") or {}).items()
|
|
65
|
+
if k in ("runtime", "os", "device")
|
|
66
|
+
}
|
|
67
|
+
except Exception:
|
|
68
|
+
# Never let scrubbing crash the reporter
|
|
69
|
+
pass
|
|
70
|
+
return event
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def init(component: str = "c3", version: str = "unknown") -> bool:
|
|
74
|
+
"""Initialize Sentry if all opt-in conditions are met. Idempotent.
|
|
75
|
+
|
|
76
|
+
Returns True if Sentry was initialized in this call (or already was),
|
|
77
|
+
False if any precondition was missing.
|
|
78
|
+
"""
|
|
79
|
+
global _INITIALIZED
|
|
80
|
+
if _INITIALIZED:
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
dsn = os.environ.get("SENTRY_DSN", "").strip()
|
|
84
|
+
if not dsn:
|
|
85
|
+
return False
|
|
86
|
+
if not _user_opted_in():
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
import sentry_sdk # type: ignore[import-not-found]
|
|
91
|
+
except ImportError:
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
sentry_sdk.init(
|
|
96
|
+
dsn=dsn,
|
|
97
|
+
release=f"{component}@{version}",
|
|
98
|
+
environment=os.environ.get("C3_ENV", "production"),
|
|
99
|
+
traces_sample_rate=0.0, # no perf tracing
|
|
100
|
+
profiles_sample_rate=0.0, # no profiling
|
|
101
|
+
send_default_pii=False,
|
|
102
|
+
attach_stacktrace=True,
|
|
103
|
+
before_send=_scrub_event,
|
|
104
|
+
)
|
|
105
|
+
sentry_sdk.set_tag("c3.component", component)
|
|
106
|
+
_INITIALIZED = True
|
|
107
|
+
return True
|
|
108
|
+
except Exception:
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def capture_error(exc: BaseException, *, component: str | None = None) -> None:
|
|
113
|
+
"""Best-effort error capture; no-op if Sentry is not initialized."""
|
|
114
|
+
if not _INITIALIZED:
|
|
115
|
+
return
|
|
116
|
+
try:
|
|
117
|
+
import sentry_sdk # type: ignore[import-not-found]
|
|
118
|
+
with sentry_sdk.push_scope() as scope:
|
|
119
|
+
if component:
|
|
120
|
+
scope.set_tag("c3.component", component)
|
|
121
|
+
sentry_sdk.capture_exception(exc)
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|