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.
Files changed (150) hide show
  1. cli/__init__.py +1 -0
  2. cli/_hook_utils.py +99 -0
  3. cli/c3.py +6152 -0
  4. cli/commands/__init__.py +1 -0
  5. cli/commands/common.py +312 -0
  6. cli/commands/parser.py +286 -0
  7. cli/docs.html +3178 -0
  8. cli/edits.html +878 -0
  9. cli/hook_auto_snapshot.py +142 -0
  10. cli/hook_c3_signal.py +61 -0
  11. cli/hook_c3read.py +116 -0
  12. cli/hook_edit_ledger.py +213 -0
  13. cli/hook_edit_unlock.py +170 -0
  14. cli/hook_filter.py +130 -0
  15. cli/hook_ghost_files.py +238 -0
  16. cli/hook_pretool_enforce.py +334 -0
  17. cli/hook_read.py +200 -0
  18. cli/hook_session_stats.py +62 -0
  19. cli/hook_terse_advisor.py +190 -0
  20. cli/hub.html +3764 -0
  21. cli/hub_server.py +1619 -0
  22. cli/mcp_proxy.py +428 -0
  23. cli/mcp_server.py +660 -0
  24. cli/server.py +2985 -0
  25. cli/tools/__init__.py +4 -0
  26. cli/tools/_helpers.py +65 -0
  27. cli/tools/agent.py +1165 -0
  28. cli/tools/compress.py +215 -0
  29. cli/tools/delegate.py +1184 -0
  30. cli/tools/edit.py +313 -0
  31. cli/tools/edits.py +118 -0
  32. cli/tools/filter.py +285 -0
  33. cli/tools/impact.py +163 -0
  34. cli/tools/memory.py +469 -0
  35. cli/tools/read.py +224 -0
  36. cli/tools/search.py +337 -0
  37. cli/tools/session.py +95 -0
  38. cli/tools/shell.py +193 -0
  39. cli/tools/status.py +306 -0
  40. cli/tools/validate.py +310 -0
  41. cli/ui/api.js +36 -0
  42. cli/ui/app.js +207 -0
  43. cli/ui/components/chat.js +758 -0
  44. cli/ui/components/dashboard.js +689 -0
  45. cli/ui/components/edits.js +220 -0
  46. cli/ui/components/instructions.js +481 -0
  47. cli/ui/components/memory.js +626 -0
  48. cli/ui/components/sessions.js +606 -0
  49. cli/ui/components/settings.js +1404 -0
  50. cli/ui/components/sidebar.js +156 -0
  51. cli/ui/icons.js +51 -0
  52. cli/ui/shared.js +119 -0
  53. cli/ui/theme.js +22 -0
  54. cli/ui.html +168 -0
  55. cli/ui_legacy.html +6797 -0
  56. cli/ui_nano.html +503 -0
  57. code_context_control-2.28.0.dist-info/METADATA +248 -0
  58. code_context_control-2.28.0.dist-info/RECORD +150 -0
  59. code_context_control-2.28.0.dist-info/WHEEL +5 -0
  60. code_context_control-2.28.0.dist-info/entry_points.txt +4 -0
  61. code_context_control-2.28.0.dist-info/licenses/LICENSE +201 -0
  62. code_context_control-2.28.0.dist-info/top_level.txt +5 -0
  63. core/__init__.py +75 -0
  64. core/config.py +269 -0
  65. core/ide.py +188 -0
  66. oracle/__init__.py +1 -0
  67. oracle/config.py +75 -0
  68. oracle/oracle.html +3900 -0
  69. oracle/oracle_server.py +663 -0
  70. oracle/services/__init__.py +1 -0
  71. oracle/services/c3_bridge.py +210 -0
  72. oracle/services/chat_engine.py +1103 -0
  73. oracle/services/chat_store.py +155 -0
  74. oracle/services/cross_memory.py +154 -0
  75. oracle/services/federated_graph.py +463 -0
  76. oracle/services/health_checker.py +117 -0
  77. oracle/services/insight_engine.py +307 -0
  78. oracle/services/memory_reader.py +106 -0
  79. oracle/services/memory_writer.py +182 -0
  80. oracle/services/ollama_bridge.py +332 -0
  81. oracle/services/project_scanner.py +87 -0
  82. oracle/services/review_agent.py +206 -0
  83. services/__init__.py +1 -0
  84. services/activity_log.py +93 -0
  85. services/agent_base.py +124 -0
  86. services/agents.py +1529 -0
  87. services/auto_memory.py +407 -0
  88. services/bench/__init__.py +6 -0
  89. services/bench/external/__init__.py +29 -0
  90. services/bench/external/aider_polyglot.py +405 -0
  91. services/bench/external/swe_bench.py +485 -0
  92. services/benchmark_dashboard.py +596 -0
  93. services/claude_md.py +785 -0
  94. services/compressor.py +592 -0
  95. services/context_snapshot.py +356 -0
  96. services/conversation_store.py +870 -0
  97. services/doc_index.py +537 -0
  98. services/e2e_benchmark.py +2884 -0
  99. services/e2e_evaluator.py +396 -0
  100. services/e2e_tasks.py +743 -0
  101. services/edit_ledger.py +459 -0
  102. services/embedding_index.py +341 -0
  103. services/error_reporting.py +123 -0
  104. services/file_memory.py +734 -0
  105. services/hub_service.py +585 -0
  106. services/indexer.py +712 -0
  107. services/memory.py +318 -0
  108. services/memory_consolidator.py +538 -0
  109. services/memory_graph.py +382 -0
  110. services/memory_grounder.py +304 -0
  111. services/memory_scorer.py +246 -0
  112. services/metrics.py +86 -0
  113. services/notifications.py +209 -0
  114. services/ollama_client.py +201 -0
  115. services/output_filter.py +488 -0
  116. services/parser.py +1238 -0
  117. services/project_manager.py +579 -0
  118. services/protocol.py +306 -0
  119. services/proxy_state.py +152 -0
  120. services/retrieval_broker.py +129 -0
  121. services/router.py +414 -0
  122. services/runtime.py +326 -0
  123. services/session_benchmark.py +1945 -0
  124. services/session_manager.py +1026 -0
  125. services/session_preloader.py +251 -0
  126. services/text_index.py +90 -0
  127. services/tool_classifier.py +176 -0
  128. services/transcript_index.py +340 -0
  129. services/validation_cache.py +155 -0
  130. services/vector_store.py +299 -0
  131. services/version_tracker.py +271 -0
  132. services/watcher.py +192 -0
  133. tui/__init__.py +0 -0
  134. tui/backend.py +59 -0
  135. tui/main.py +145 -0
  136. tui/screens/__init__.py +1 -0
  137. tui/screens/benchmark_view.py +109 -0
  138. tui/screens/claudemd_view.py +46 -0
  139. tui/screens/compress_view.py +52 -0
  140. tui/screens/index_view.py +74 -0
  141. tui/screens/init_view.py +82 -0
  142. tui/screens/mcp_view.py +73 -0
  143. tui/screens/optimize_view.py +41 -0
  144. tui/screens/pipe_view.py +46 -0
  145. tui/screens/projects_view.py +355 -0
  146. tui/screens/search_view.py +55 -0
  147. tui/screens/session_view.py +143 -0
  148. tui/screens/stats.py +158 -0
  149. tui/screens/ui_view.py +54 -0
  150. tui/theme.tcss +335 -0
@@ -0,0 +1,1026 @@
1
+ """
2
+ Session State Manager
3
+
4
+ Maintains compressed state between Claude Code sessions:
5
+ - Auto-saves decisions, changes, and context
6
+ - Generates optimized CLAUDE.md files
7
+ - Tracks token usage patterns for optimization suggestions
8
+ - Provides session continuity without re-explaining everything
9
+ """
10
+ import json
11
+ import os
12
+ import re
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ from core import count_tokens
18
+ from core.ide import detect_ide, load_ide_config
19
+
20
+
21
+ class SessionManager:
22
+ """Manages session state and generates CLAUDE.md files."""
23
+
24
+ _SOURCE_BY_IDE = {
25
+ "claude-code": "claude",
26
+ "vscode": "vscode",
27
+ "cursor": "cursor",
28
+ "codex": "codex",
29
+ "gemini": "gemini",
30
+ "antigravity": "antigravity",
31
+ }
32
+ _IDE_BY_SOURCE = {
33
+ "claude": "claude-code",
34
+ "vscode": "vscode",
35
+ "cursor": "cursor",
36
+ "codex": "codex",
37
+ "gemini": "gemini",
38
+ "antigravity": "antigravity",
39
+ }
40
+
41
+ # Default context budget threshold (overridable via .c3/config.json "context_budget" key)
42
+ DEFAULT_BUDGET_THRESHOLDS = {
43
+ "threshold": 35000,
44
+ }
45
+
46
+ def __init__(self, project_path: str, data_dir: str = ".c3/sessions", ollama_client=None):
47
+ self.project_path = Path(project_path)
48
+ self.data_dir = self.project_path / data_dir
49
+ self.data_dir.mkdir(parents=True, exist_ok=True)
50
+ self.current_session = None
51
+ self.ollama_client = ollama_client
52
+
53
+ # Analytics now in a dedicated directory
54
+ analytics_dir = self.project_path / ".c3" / "analytics"
55
+ analytics_dir.mkdir(parents=True, exist_ok=True)
56
+ self.analytics_file = analytics_dir / "analytics.json"
57
+
58
+ # Migrate legacy analytics if it exists in the sessions folder
59
+ legacy_analytics = self.data_dir / "analytics.json"
60
+ if legacy_analytics.exists() and not self.analytics_file.exists():
61
+ try:
62
+ legacy_analytics.replace(self.analytics_file)
63
+ except Exception:
64
+ pass
65
+
66
+ self._budget_file = self.project_path / ".c3" / "context_budget.json"
67
+ self._budget_thresholds = self._load_budget_thresholds()
68
+
69
+ @staticmethod
70
+ def _normalize_source_system(source_system: Optional[str]) -> Optional[str]:
71
+ """Normalize caller-system labels to canonical values."""
72
+ if not source_system:
73
+ return None
74
+ raw = str(source_system).strip().lower()
75
+ aliases = {
76
+ "claude-code": "claude",
77
+ "claude": "claude",
78
+ "vscode": "vscode",
79
+ "copilot": "vscode",
80
+ "vs-code": "vscode",
81
+ "cursor": "cursor",
82
+ "codex": "codex",
83
+ "openai-codex": "codex",
84
+ "gemini": "gemini",
85
+ "antigravity": "antigravity",
86
+ }
87
+ return aliases.get(raw, raw)
88
+
89
+ def _detect_ide_name(self) -> str:
90
+ """Infer current IDE from saved config, then project markers."""
91
+ ide_name = load_ide_config(str(self.project_path))
92
+ if ide_name == "claude-code":
93
+ # If no explicit config exists, marker-based detection can refine this.
94
+ ide_name = detect_ide(str(self.project_path))
95
+ return ide_name or "claude-code"
96
+
97
+ def start_session(self, description: str = "", source_system: Optional[str] = None) -> dict:
98
+ """Start a new session."""
99
+ session_id = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
100
+ source_ide = self._detect_ide_name()
101
+ normalized_source = self._normalize_source_system(source_system)
102
+ if normalized_source:
103
+ source_ide = self._IDE_BY_SOURCE.get(normalized_source, source_ide)
104
+ source_system_value = normalized_source or self._SOURCE_BY_IDE.get(source_ide, "manual")
105
+ self.current_session = {
106
+ "id": session_id,
107
+ "started": datetime.now(timezone.utc).isoformat(),
108
+ "description": description,
109
+ "source_system": source_system_value,
110
+ "source_ide": source_ide,
111
+ "decisions": [],
112
+ "files_touched": [],
113
+ "key_changes": [],
114
+ "context_notes": [],
115
+ "tool_calls": [],
116
+ "token_usage": {"estimated_saved": 0, "estimated_used": 0, "measured_ops": 0},
117
+ "context_budget": {
118
+ "response_tokens": 0,
119
+ "call_count": 0,
120
+ "peak_tokens": 0,
121
+ "by_tool": {},
122
+ "c3_calls": 0,
123
+ "native_calls": 0,
124
+ "auto_snapshot_fired": False,
125
+ },
126
+ }
127
+ return {
128
+ "session_id": session_id,
129
+ "status": "started",
130
+ "source_system": source_system_value,
131
+ "source_ide": source_ide,
132
+ }
133
+
134
+ def log_decision(self, decision: str, reasoning: str = ""):
135
+ """Log a decision made during the session."""
136
+ if not self.current_session:
137
+ self.start_session()
138
+ self.current_session["decisions"].append({
139
+ "decision": decision,
140
+ "reasoning": reasoning,
141
+ "timestamp": datetime.now(timezone.utc).isoformat()
142
+ })
143
+
144
+ def log_file_change(self, filepath: str, change_type: str, summary: str = ""):
145
+ """Log a file change."""
146
+ if not self.current_session:
147
+ self.start_session()
148
+ self.current_session["files_touched"].append({
149
+ "file": filepath,
150
+ "type": change_type, # created, modified, deleted
151
+ "summary": summary,
152
+ "timestamp": datetime.now(timezone.utc).isoformat()
153
+ })
154
+
155
+ def log_tool_call(self, tool_name: str, args: dict, result_summary: str = ""):
156
+ """Log an MCP tool invocation to the current session."""
157
+ if not self.current_session:
158
+ self.start_session()
159
+ self.current_session["tool_calls"].append({
160
+ "tool": tool_name,
161
+ "args": args,
162
+ "result_summary": result_summary[:200],
163
+ "timestamp": datetime.now(timezone.utc).isoformat(),
164
+ })
165
+ # Heuristic savings estimate from summaries like "122288->5085tok".
166
+ self._update_token_usage_estimate(result_summary)
167
+
168
+ @staticmethod
169
+ def _parse_summary_token_pair(result_summary: str) -> tuple[int, int] | None:
170
+ """Parse token pair from summary text, returning (raw_tokens, optimized_tokens)."""
171
+ if not result_summary:
172
+ return None
173
+ m = re.search(r"(\d+)\s*->\s*(\d+)\s*tok\b", result_summary, re.IGNORECASE)
174
+ if not m:
175
+ return None
176
+ try:
177
+ raw = int(m.group(1))
178
+ optimized = int(m.group(2))
179
+ except Exception:
180
+ return None
181
+ if raw < 0 or optimized < 0:
182
+ return None
183
+ return raw, optimized
184
+
185
+ def _update_token_usage_estimate(self, result_summary: str) -> None:
186
+ """Update session token_usage from tool result summaries."""
187
+ if not self.current_session:
188
+ return
189
+ pair = self._parse_summary_token_pair(result_summary)
190
+ if not pair:
191
+ return
192
+ raw, optimized = pair
193
+ token_usage = self.current_session.setdefault("token_usage", {})
194
+ token_usage["estimated_saved"] = int(token_usage.get("estimated_saved", 0)) + max(0, raw - optimized)
195
+ token_usage["estimated_used"] = int(token_usage.get("estimated_used", 0)) + optimized
196
+ token_usage["measured_ops"] = int(token_usage.get("measured_ops", 0)) + 1
197
+
198
+ def reset_budget(self, initial_tokens: int = 0) -> None:
199
+ """Reset the current session's context budget (typically after /clear)."""
200
+ if not self.current_session:
201
+ return
202
+ budget = self.current_session["context_budget"]
203
+ budget["response_tokens"] = initial_tokens
204
+ budget["call_count"] = 0
205
+ budget["peak_tokens"] = initial_tokens
206
+ budget["by_tool"] = {}
207
+ self._persist_budget()
208
+
209
+ def is_over_budget(self) -> bool:
210
+ """Return True if cumulative response tokens exceed the threshold."""
211
+ if not self.current_session:
212
+ return False
213
+ total = self.current_session["context_budget"]["response_tokens"]
214
+ return total >= self._budget_thresholds["threshold"]
215
+
216
+ def add_context_note(self, note: str):
217
+ """Add a context note for future sessions."""
218
+ if not self.current_session:
219
+ self.start_session()
220
+ self.current_session["context_notes"].append(note)
221
+
222
+ def save_session(self, summary: str = "") -> dict:
223
+ """Save current session to disk."""
224
+ if not self.current_session:
225
+ return {"error": "No active session"}
226
+
227
+ self.current_session["ended"] = datetime.now(timezone.utc).isoformat()
228
+
229
+ # Determine summary: user provided > AI generated > Heuristic auto
230
+ if summary:
231
+ self.current_session["summary"] = summary
232
+ elif self.ollama_client and self.ollama_client.is_available():
233
+ self.current_session["summary"] = self._ai_summarize()
234
+ else:
235
+ self.current_session["summary"] = self._auto_summarize()
236
+
237
+ # Compute duration
238
+ try:
239
+ started = datetime.fromisoformat(self.current_session["started"])
240
+ ended = datetime.fromisoformat(self.current_session["ended"])
241
+ duration_seconds = int((ended - started).total_seconds())
242
+ except (ValueError, KeyError):
243
+ duration_seconds = 0
244
+ self.current_session["duration_seconds"] = duration_seconds
245
+ self.current_session["duration"] = self._format_duration(duration_seconds)
246
+
247
+ session_file = self.data_dir / f"session_{self.current_session['id']}.json"
248
+ with open(session_file, 'w', encoding='utf-8') as f:
249
+ json.dump(self.current_session, f, indent=2)
250
+
251
+ # Update analytics
252
+ self._update_analytics()
253
+
254
+ result = {
255
+ "session_id": self.current_session["id"],
256
+ "saved_to": str(session_file),
257
+ "decisions": len(self.current_session["decisions"]),
258
+ "files_touched": len(self.current_session["files_touched"]),
259
+ "duration_seconds": duration_seconds,
260
+ "duration": self.current_session["duration"],
261
+ }
262
+ self.current_session = None
263
+ return result
264
+
265
+ def load_session(self, session_id: str = "latest") -> dict:
266
+ """Load a previous session's context."""
267
+ if session_id == "latest":
268
+ sessions = sorted(self.data_dir.glob("session_*.json"), reverse=True)
269
+ if not sessions:
270
+ return {"error": "No previous sessions found"}
271
+ session_file = sessions[0]
272
+ else:
273
+ session_file = self.data_dir / f"session_{session_id}.json"
274
+
275
+ if not session_file.exists():
276
+ return {"error": f"Session not found: {session_id}"}
277
+
278
+ with open(session_file, encoding='utf-8') as f:
279
+ session = json.load(f)
280
+
281
+ return session
282
+
283
+ def get_session_context(self, n_sessions: int = 3) -> str:
284
+ """Get compressed context from recent sessions, ready for Claude."""
285
+ sessions = sorted(self.data_dir.glob("session_*.json"), reverse=True)[:n_sessions]
286
+
287
+ if not sessions:
288
+ return "No previous session history."
289
+
290
+ context_parts = ["# Session History (Compressed)\n"]
291
+
292
+ for sf in sessions:
293
+ with open(sf, encoding='utf-8') as f:
294
+ s = json.load(f)
295
+
296
+ part = f"## Session: {s.get('id', 'unknown')}\n"
297
+ part += f"**When:** {s.get('started', 'unknown')[:10]}\n"
298
+ if s.get('summary'):
299
+ part += f"**Summary:** {s['summary']}\n"
300
+
301
+ if s.get('decisions'):
302
+ part += "**Decisions:**\n"
303
+ for d in s['decisions'][:5]:
304
+ part += f"- {d['decision']}\n"
305
+
306
+ if s.get('files_touched'):
307
+ files = [f"{ft['type']}: {ft['file']}" for ft in s['files_touched'][:10]]
308
+ part += f"**Files:** {', '.join(files)}\n"
309
+
310
+ if s.get('context_notes'):
311
+ part += "**Notes:**\n"
312
+ for note in s['context_notes'][:3]:
313
+ part += f"- {note}\n"
314
+
315
+ context_parts.append(part)
316
+
317
+ return '\n'.join(context_parts)
318
+
319
+ def list_sessions(self, n: int = 10) -> list:
320
+ """List recent sessions."""
321
+ sessions = sorted(self.data_dir.glob("session_*.json"), reverse=True)[:n]
322
+ result = []
323
+ for sf in sessions:
324
+ with open(sf, encoding='utf-8') as f:
325
+ s = json.load(f)
326
+ # Compute duration if missing from stored session
327
+ duration_seconds = s.get("duration_seconds", 0)
328
+ duration = s.get("duration", "")
329
+ if not duration and s.get("started") and s.get("ended"):
330
+ try:
331
+ started = datetime.fromisoformat(s["started"])
332
+ ended = datetime.fromisoformat(s["ended"])
333
+ duration_seconds = int((ended - started).total_seconds())
334
+ duration = self._format_duration(duration_seconds)
335
+ except (ValueError, KeyError):
336
+ pass
337
+
338
+ budget = s.get("context_budget", {})
339
+ result.append({
340
+ "id": s.get("id"),
341
+ "started": s.get("started", ""),
342
+ "ended": s.get("ended", ""),
343
+ "summary": s.get("summary", "")[:100],
344
+ "description": (s.get("description", "") or "")[:80],
345
+ "source_system": s.get("source_system", ""),
346
+ "source_ide": s.get("source_ide", ""),
347
+ "decisions": len(s.get("decisions", [])),
348
+ "files": len(s.get("files_touched", [])),
349
+ "tool_calls": len(s.get("tool_calls", [])),
350
+ "context_notes": len(s.get("context_notes", [])),
351
+ "duration": duration,
352
+ "duration_seconds": duration_seconds,
353
+ "response_tokens": budget.get("response_tokens", 0),
354
+ "by_tool": budget.get("by_tool", {}),
355
+ })
356
+ return result
357
+
358
+ def generate_claude_md(self, include_sessions: bool = True) -> str:
359
+ """Auto-generate token-efficient project context for instructions files."""
360
+ parts = []
361
+
362
+ # Project structure
363
+ parts.append("# Project Context\n")
364
+ parts.append(self._scan_project_structure())
365
+
366
+ # Tech stack detection
367
+ parts.append("\n## Tech Stack\n")
368
+ parts.append(self._detect_tech_stack())
369
+
370
+ # Key files (conventional entry points + session history)
371
+ key_files = self._detect_key_files()
372
+ if key_files:
373
+ parts.append("\n## Key Files\n")
374
+ for kf in key_files[:5]:
375
+ parts.append(f"- `{kf['file']}` — {kf['reason']}")
376
+
377
+ # Key facts from memory store (if wired in)
378
+ memory_store = getattr(self, '_memory_store', None)
379
+ if memory_store is not None:
380
+ promoted = [
381
+ f for f in getattr(memory_store, 'facts', [])
382
+ if f.get("relevance_count", 0) >= 3
383
+ ]
384
+ if promoted:
385
+ parts.append("\n## Key Facts (use c3_memory for more)\n")
386
+ for f in promoted[:5]:
387
+ parts.append(f"- {f['fact'][:120]}")
388
+
389
+ return '\n'.join(parts)
390
+
391
+ def _detect_key_files(self) -> list:
392
+ """Identify key files from session history and conventional entry points."""
393
+ key_files = []
394
+ seen = set()
395
+
396
+ # Hot files from session history
397
+ session_dir = self.project_path / ".c3" / "sessions"
398
+ if session_dir.exists():
399
+ file_counts = {}
400
+ for sf in sorted(session_dir.glob("session_*.json"), reverse=True)[:20]:
401
+ try:
402
+ with open(sf, encoding='utf-8') as f:
403
+ s = json.load(f)
404
+ for ft in s.get("files_touched", []):
405
+ fname = ft.get("file", "")
406
+ if fname:
407
+ file_counts[fname] = file_counts.get(fname, 0) + 1
408
+ except Exception:
409
+ continue
410
+
411
+ for fname, count in sorted(file_counts.items(), key=lambda x: -x[1])[:5]:
412
+ if count >= 2 and fname not in seen:
413
+ key_files.append({"file": fname, "reason": f"edited in {count} sessions"})
414
+ seen.add(fname)
415
+
416
+ # Conventional entry points
417
+ entry_points = [
418
+ ("main.py", "Python entry point"),
419
+ ("app.py", "Application entry point"),
420
+ ("index.ts", "TypeScript entry point"),
421
+ ("index.js", "JavaScript entry point"),
422
+ ("src/index.ts", "Source entry point"),
423
+ ("src/index.js", "Source entry point"),
424
+ ("src/main.ts", "Source entry point"),
425
+ ("src/App.tsx", "React app root"),
426
+ ("cli/mcp_server.py", "MCP server entry"),
427
+ ]
428
+ for filepath, reason in entry_points:
429
+ if (self.project_path / filepath).exists() and filepath not in seen:
430
+ key_files.append({"file": filepath, "reason": reason})
431
+ seen.add(filepath)
432
+
433
+ return key_files
434
+
435
+ def save_claude_md(self, instructions_file: str = "CLAUDE.md", template: str = "") -> dict:
436
+ """Generate and save instructions file to the project root.
437
+
438
+ Args:
439
+ instructions_file: Target filename, e.g. "CLAUDE.md",
440
+ ".github/copilot-instructions.md", ".cursorrules".
441
+ template: Optional static instructions to prepend.
442
+ """
443
+ auto_content = self.generate_claude_md()
444
+
445
+ if template:
446
+ # Merge template with auto-generated context
447
+ content = template.rstrip() + "\n\n---\n\n" + auto_content.lstrip()
448
+ else:
449
+ content = auto_content
450
+
451
+ output_path = self.project_path / instructions_file
452
+ output_path.parent.mkdir(parents=True, exist_ok=True)
453
+
454
+ # Check for existing file
455
+ if output_path.exists():
456
+ existing = output_path.read_text(encoding="utf-8")
457
+ # Preserve user-written sections
458
+ if "# User Notes" in existing:
459
+ user_section = existing[existing.index("# User Notes"):]
460
+ content += f"\n\n{user_section}"
461
+
462
+ output_path.write_text(content, encoding="utf-8")
463
+ tokens = count_tokens(content)
464
+
465
+ return {
466
+ "path": str(output_path),
467
+ "tokens": tokens,
468
+ "status": "saved"
469
+ }
470
+
471
+ @staticmethod
472
+ def _format_duration(seconds: int) -> str:
473
+ """Return a human-readable duration string (e.g., '2m 34s', '1h 5m')."""
474
+ if seconds < 0:
475
+ seconds = 0
476
+ if seconds < 60:
477
+ return f"{seconds}s"
478
+ minutes, secs = divmod(seconds, 60)
479
+ if minutes < 60:
480
+ return f"{minutes}m {secs}s" if secs else f"{minutes}m"
481
+ hours, mins = divmod(minutes, 60)
482
+ return f"{hours}h {mins}m" if mins else f"{hours}h"
483
+
484
+ def _auto_summarize(self) -> str:
485
+ """Auto-generate session summary."""
486
+ parts = []
487
+ if self.current_session.get("description"):
488
+ parts.append(self.current_session["description"])
489
+
490
+ files = self.current_session.get("files_touched", [])
491
+ if files:
492
+ parts.append(f"Touched {len(files)} files")
493
+
494
+ decisions = self.current_session.get("decisions", [])
495
+ if decisions:
496
+ parts.append(f"Made {len(decisions)} decisions")
497
+ if decisions:
498
+ parts.append(decisions[-1]["decision"]) # Most recent
499
+
500
+ tool_calls = self.current_session.get("tool_calls", [])
501
+ if tool_calls:
502
+ parts.append(f"{len(tool_calls)} tool calls")
503
+
504
+ return ". ".join(parts) if parts else "Session with no recorded activity"
505
+
506
+ def _ai_summarize(self, model: str = "gemma3n:latest") -> str:
507
+ """Use local AI to generate a semantic summary of the session."""
508
+ if not self.ollama_client:
509
+ return self._auto_summarize()
510
+
511
+ heuristic = self._auto_summarize()
512
+ # Extract last few tool calls for context
513
+ calls = self.current_session.get("tool_calls", [])
514
+ history = []
515
+ for c in calls[-10:]:
516
+ history.append(f"Tool: {c.get('tool')} Args: {json.dumps(c.get('args', {}))} Result: {c.get('result_summary')}")
517
+
518
+ prompt = (
519
+ "Summarize this coding session in one clear, technical sentence. "
520
+ "Focus on the 'why' and the primary outcome.\n\n"
521
+ f"Heuristic Data: {heuristic}\n"
522
+ "Recent Activity:\n" + "\n".join(history) + "\n\n"
523
+ "Summary:"
524
+ )
525
+
526
+ try:
527
+ summary = self.ollama_client.generate(
528
+ prompt=prompt,
529
+ model=model,
530
+ system="You are a senior developer writing a git-style summary of a task.",
531
+ max_tokens=64,
532
+ temperature=0.3
533
+ )
534
+ return summary.strip() if summary else heuristic
535
+ except Exception:
536
+ return heuristic
537
+
538
+ # Known extensionless files that are legitimate
539
+ _KNOWN_NO_EXT = {
540
+ 'Makefile', 'Dockerfile', 'Procfile', 'Vagrantfile', 'Gemfile',
541
+ 'Rakefile', 'Guardfile', 'Brewfile', 'Justfile', 'Taskfile',
542
+ 'LICENSE', 'LICENCE', 'CODEOWNERS', 'CACHEDIR.TAG',
543
+ }
544
+
545
+ @staticmethod
546
+ def _is_valid_filename(name: str) -> bool:
547
+ """Filter out junk/artifact files that shouldn't appear in the project tree."""
548
+ # Must have at least one alphanumeric character
549
+ if not any(c.isalnum() for c in name):
550
+ return False
551
+ # Must start with a letter, digit, dot, or underscore
552
+ if not name[0].isalpha() and name[0] not in '.0123456789_':
553
+ return False
554
+ # Reject names that are purely numeric (likely artifacts)
555
+ if name.replace('.', '').replace('_', '').isdigit():
556
+ return False
557
+ # Reject names with shell/template metacharacters
558
+ if any(c in name for c in '{}()$`'):
559
+ return False
560
+ # Reject extensionless files unless they're known valid names
561
+ if '.' not in name and name not in SessionManager._KNOWN_NO_EXT:
562
+ return False
563
+ return True
564
+
565
+ def _scan_project_structure(self) -> str:
566
+ """Scan project and generate compressed structure."""
567
+ skip = {'node_modules', '.git', '__pycache__', '.c3', 'venv',
568
+ 'env', '.venv', 'dist', 'build', '.next'}
569
+
570
+ structure = ["```"]
571
+ for root, dirs, files in os.walk(self.project_path):
572
+ dirs[:] = sorted(d for d in dirs if d not in skip)
573
+ level = len(Path(root).relative_to(self.project_path).parts)
574
+
575
+ indent = " " * level
576
+ dirname = os.path.basename(root)
577
+ valid_files = [f for f in sorted(files) if self._is_valid_filename(f)]
578
+
579
+ if level >= 2:
580
+ # Deep subdirs: show name + file count only, no individual file listing
581
+ count_str = f" ({len(valid_files)} files)" if valid_files else ""
582
+ structure.append(f"{indent}{dirname}/{count_str}")
583
+ dirs[:] = [] # stop os.walk from recursing deeper
584
+ continue
585
+
586
+ structure.append(f"{indent}{dirname}/")
587
+ for f in valid_files[:15]:
588
+ structure.append(f"{indent} {f}")
589
+ if len(valid_files) > 15:
590
+ structure.append(f"{indent} ... +{len(valid_files) - 15} more")
591
+
592
+ structure.append("```")
593
+ return '\n'.join(structure)
594
+
595
+ def _detect_tech_stack(self) -> str:
596
+ """Detect tech stack from project files."""
597
+ indicators = {
598
+ "package.json": "Node.js",
599
+ "tsconfig.json": "TypeScript",
600
+ "requirements.txt": "Python",
601
+ "Pipfile": "Python (Pipenv)",
602
+ "pyproject.toml": "Python (Modern)",
603
+ "Cargo.toml": "Rust",
604
+ "go.mod": "Go",
605
+ "DESCRIPTION": "R Package",
606
+ "app.R": "R Shiny",
607
+ "server.R": "R Shiny",
608
+ "docker-compose.yml": "Docker",
609
+ "Dockerfile": "Docker",
610
+ ".env": "Environment vars",
611
+ "next.config.js": "Next.js",
612
+ "vite.config.ts": "Vite",
613
+ "tailwind.config.js": "Tailwind CSS",
614
+ }
615
+
616
+ detected = []
617
+ for filename, tech in indicators.items():
618
+ if (self.project_path / filename).exists():
619
+ detected.append(tech)
620
+
621
+ # Check package.json for frameworks
622
+ pkg_json = self.project_path / "package.json"
623
+ if pkg_json.exists():
624
+ try:
625
+ with open(pkg_json, encoding='utf-8') as f:
626
+ pkg = json.load(f)
627
+ deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
628
+ for dep, tech in [("react", "React"), ("vue", "Vue"), ("angular", "Angular"),
629
+ ("express", "Express"), ("fastify", "Fastify")]:
630
+ if dep in deps:
631
+ detected.append(tech)
632
+ except Exception:
633
+ pass
634
+
635
+ return ', '.join(detected) if detected else "Could not auto-detect"
636
+
637
+ def _detect_patterns(self) -> str:
638
+ """Detect coding patterns and conventions."""
639
+ patterns = []
640
+
641
+ # Check for common patterns
642
+ src_files = list(self.project_path.rglob("*.py"))[:20]
643
+ src_files += list(self.project_path.rglob("*.ts"))[:20]
644
+ src_files += list(self.project_path.rglob("*.js"))[:20]
645
+
646
+ has_tests = any(self.project_path.rglob("test_*")) or any(self.project_path.rglob("*.test.*"))
647
+ has_types = any(self.project_path.rglob("*.d.ts")) or any(self.project_path.rglob("types.*"))
648
+ has_ci = (self.project_path / ".github" / "workflows").exists()
649
+
650
+ if has_tests:
651
+ patterns.append("Has test files")
652
+ if has_types:
653
+ patterns.append("Uses TypeScript types")
654
+ if has_ci:
655
+ patterns.append("Has CI/CD (GitHub Actions)")
656
+
657
+ return '\n'.join(f"- {p}" for p in patterns) if patterns else "No patterns auto-detected"
658
+
659
+ def _generate_shortcuts(self) -> str:
660
+ """Generate token-efficient shortcut references."""
661
+ shortcuts = [
662
+ "When referencing this project, use these shortcuts:",
663
+ "- `SRC` = main source directory",
664
+ "- `TESTS` = test directory",
665
+ "- `CFG` = configuration files",
666
+ "- `DEPS` = dependencies (package.json / requirements.txt)",
667
+ ]
668
+ return '\n'.join(shortcuts)
669
+
670
+ def _update_analytics(self):
671
+ """Update session analytics."""
672
+ analytics = {}
673
+ if self.analytics_file.exists():
674
+ try:
675
+ with open(self.analytics_file, encoding='utf-8') as f:
676
+ analytics = json.load(f)
677
+ except Exception:
678
+ analytics = {}
679
+
680
+ analytics["total_sessions"] = analytics.get("total_sessions", 0) + 1
681
+ analytics["last_session"] = datetime.now(timezone.utc).isoformat()
682
+
683
+ total_decisions = analytics.get("total_decisions", 0)
684
+ total_decisions += len(self.current_session.get("decisions", []))
685
+ analytics["total_decisions"] = total_decisions
686
+
687
+ with open(self.analytics_file, 'w', encoding='utf-8') as f:
688
+ json.dump(analytics, f, indent=2)
689
+
690
+ def parse_claude_session_tokens(self, project_path: str = "", detailed: bool = False) -> dict:
691
+ """Read Claude Code's session JSONL files for real token usage stats.
692
+
693
+ Scopes to the current project directory to avoid summing tokens
694
+ from unrelated projects.
695
+
696
+ When detailed=True, also returns a 'sessions' list with per-session breakdown
697
+ and timestamps.
698
+ """
699
+ import re
700
+ home = Path.home()
701
+ results = {"sessions_found": 0, "total_input_tokens": 0, "total_output_tokens": 0,
702
+ "cache_creation_tokens": 0, "cache_read_tokens": 0}
703
+ if detailed:
704
+ results["sessions"] = []
705
+ projects_dir = home / ".claude" / "projects"
706
+ if not projects_dir.exists():
707
+ return results
708
+
709
+ proj_path = Path(project_path or self.project_path).resolve()
710
+ proj_str = str(proj_path)
711
+
712
+ # Claude Code slugifies paths by replacing every non-alphanumeric char with '-'.
713
+ slug_primary = re.sub(r'[^a-zA-Z0-9]', '-', proj_str).lstrip('-')
714
+ # Keep legacy variant (old C3 algorithm) for backwards compatibility.
715
+ slug_legacy = proj_str.replace("\\", "--").replace("/", "--").replace(":", "").lstrip("-")
716
+
717
+ candidate_dirs = []
718
+ for slug in (slug_primary, slug_legacy):
719
+ d = projects_dir / slug
720
+ if d.is_dir() and d not in candidate_dirs:
721
+ candidate_dirs.append(d)
722
+
723
+ # If direct slug lookup misses, try a constrained name match rather than
724
+ # summing all projects (which would mix unrelated token usage).
725
+ if not candidate_dirs:
726
+ project_name = proj_path.name.lower()
727
+ for d in projects_dir.iterdir():
728
+ if not d.is_dir():
729
+ continue
730
+ name = d.name.lower()
731
+ if name.endswith(f"--{project_name}") or f"--{project_name}--" in name:
732
+ candidate_dirs.append(d)
733
+
734
+ for project_dir in candidate_dirs:
735
+ for session_file in project_dir.glob("*.jsonl"):
736
+ try:
737
+ with open(session_file, encoding="utf-8", errors="replace") as f:
738
+ found_usage = False
739
+ s_inp = s_out = s_cache_create = s_cache_read = 0
740
+ s_first_ts = s_last_ts = None
741
+ for line in f:
742
+ line = line.strip()
743
+ if not line:
744
+ continue
745
+ entry = json.loads(line)
746
+ # Usage is nested at entry.message.usage for assistant messages
747
+ msg = entry.get("message", {})
748
+ usage = msg.get("usage", {})
749
+ inp = usage.get("input_tokens", 0)
750
+ out = usage.get("output_tokens", 0)
751
+ cache_create = usage.get("cache_creation_input_tokens", 0)
752
+ cache_read = usage.get("cache_read_input_tokens", 0)
753
+ if inp or out or cache_create or cache_read:
754
+ # total_input_tokens = non-cached + cache_creation + cache_read
755
+ # This matches Claude Code's reported token usage
756
+ results["total_input_tokens"] += inp + cache_create + cache_read
757
+ results["total_output_tokens"] += out
758
+ results["cache_creation_tokens"] += cache_create
759
+ results["cache_read_tokens"] += cache_read
760
+ found_usage = True
761
+ if detailed:
762
+ s_inp += inp + cache_create + cache_read
763
+ s_out += out
764
+ s_cache_create += cache_create
765
+ s_cache_read += cache_read
766
+ if detailed:
767
+ ts = entry.get("timestamp")
768
+ if ts:
769
+ if s_first_ts is None:
770
+ s_first_ts = ts
771
+ s_last_ts = ts
772
+ if found_usage:
773
+ results["sessions_found"] += 1
774
+ if detailed:
775
+ results["sessions"].append({
776
+ "session_id": session_file.stem,
777
+ "input_tokens": s_inp,
778
+ "output_tokens": s_out,
779
+ "cache_creation_tokens": s_cache_create,
780
+ "cache_read_tokens": s_cache_read,
781
+ "started": s_first_ts,
782
+ "ended": s_last_ts,
783
+ })
784
+ except Exception:
785
+ continue
786
+ if detailed:
787
+ results["sessions"].sort(key=lambda x: x.get("started") or "", reverse=True)
788
+ return results
789
+
790
+ # ─── Hook-captured Session Stats ─────────────────────────
791
+
792
+ def get_session_stats(self, limit: int = 50) -> list:
793
+ """Read hook-captured session stats from .c3/session_stats.jsonl.
794
+
795
+ Each entry: {ts, session_id, stop_reason, cost_usd, input_tokens,
796
+ output_tokens, cache_creation_tokens, cache_read_tokens}
797
+ """
798
+ stats_path = Path(self.project_path) / ".c3" / "session_stats.jsonl"
799
+ if not stats_path.exists():
800
+ return []
801
+ entries = []
802
+ try:
803
+ with open(stats_path, encoding="utf-8") as f:
804
+ for line in f:
805
+ line = line.strip()
806
+ if line:
807
+ try:
808
+ entries.append(json.loads(line))
809
+ except Exception:
810
+ pass
811
+ except Exception:
812
+ pass
813
+ return entries[-limit:]
814
+
815
+ def get_live_session_tokens(self) -> dict:
816
+ """Read running token counts from the most recently modified Claude transcript file.
817
+
818
+ Claude Code appends to transcript JSONL files after each exchange, so this
819
+ gives per-turn live visibility into the active session's token usage.
820
+ Returns: {session_id, input_tokens, output_tokens, cache_creation_tokens,
821
+ cache_read_tokens, turns, last_modified}
822
+ """
823
+ import re as _re
824
+ home = Path.home()
825
+ projects_dir = home / ".claude" / "projects"
826
+ if not projects_dir.exists():
827
+ return {}
828
+
829
+ proj_path = Path(self.project_path).resolve()
830
+ proj_str = str(proj_path)
831
+ slug_primary = _re.sub(r"[^a-zA-Z0-9]", "-", proj_str).lstrip("-")
832
+ slug_legacy = proj_str.replace("\\", "--").replace("/", "--").replace(":", "").lstrip("-")
833
+
834
+ candidate_dirs: list[Path] = []
835
+ for slug in (slug_primary, slug_legacy):
836
+ d = projects_dir / slug
837
+ if d.is_dir() and d not in candidate_dirs:
838
+ candidate_dirs.append(d)
839
+
840
+ if not candidate_dirs:
841
+ project_name = proj_path.name.lower()
842
+ for d in projects_dir.iterdir():
843
+ if not d.is_dir():
844
+ continue
845
+ name = d.name.lower()
846
+ if name.endswith(f"--{project_name}") or f"--{project_name}--" in name:
847
+ candidate_dirs.append(d)
848
+
849
+ # Find the most recently modified JSONL file across all candidate dirs
850
+ most_recent: Optional[Path] = None
851
+ most_recent_mtime = 0.0
852
+ for d in candidate_dirs:
853
+ for f in d.glob("*.jsonl"):
854
+ try:
855
+ mtime = f.stat().st_mtime
856
+ if mtime > most_recent_mtime:
857
+ most_recent_mtime = mtime
858
+ most_recent = f
859
+ except Exception:
860
+ pass
861
+
862
+ if not most_recent:
863
+ return {}
864
+
865
+ result: dict = {
866
+ "session_id": most_recent.stem,
867
+ "input_tokens": 0,
868
+ "output_tokens": 0,
869
+ "cache_creation_tokens": 0,
870
+ "cache_read_tokens": 0,
871
+ "turns": 0,
872
+ "last_modified": most_recent_mtime,
873
+ }
874
+ try:
875
+ with open(most_recent, encoding="utf-8", errors="replace") as f:
876
+ for line in f:
877
+ line = line.strip()
878
+ if not line:
879
+ continue
880
+ try:
881
+ entry = json.loads(line)
882
+ msg = entry.get("message", {})
883
+ usage = msg.get("usage", {})
884
+ inp = usage.get("input_tokens", 0)
885
+ out = usage.get("output_tokens", 0)
886
+ cache_create = usage.get("cache_creation_input_tokens", 0)
887
+ cache_read = usage.get("cache_read_input_tokens", 0)
888
+ if inp or out or cache_create or cache_read:
889
+ result["input_tokens"] += inp + cache_create + cache_read
890
+ result["output_tokens"] += out
891
+ result["cache_creation_tokens"] += cache_create
892
+ result["cache_read_tokens"] += cache_read
893
+ result["turns"] += 1
894
+ except Exception:
895
+ pass
896
+ except Exception:
897
+ pass
898
+ return result
899
+
900
+ # ─── Context Budget ──────────────────────────────────────
901
+
902
+ def _load_budget_thresholds(self) -> dict:
903
+ """Load thresholds from .c3/config.json or use defaults.
904
+
905
+ Migrates old multi-threshold keys: if 'threshold' is not set but
906
+ 'nudge' exists, uses 'nudge' as the threshold value.
907
+ """
908
+ config_file = self.project_path / ".c3" / "config.json"
909
+ thresholds = dict(self.DEFAULT_BUDGET_THRESHOLDS)
910
+ if config_file.exists():
911
+ try:
912
+ with open(config_file, encoding='utf-8') as f:
913
+ cfg = json.load(f)
914
+ overrides = cfg.get("context_budget", {})
915
+ if "threshold" in overrides:
916
+ thresholds["threshold"] = int(overrides["threshold"])
917
+ elif "nudge" in overrides:
918
+ # Migrate old nudge → threshold
919
+ thresholds["threshold"] = int(overrides["nudge"])
920
+ except Exception:
921
+ pass
922
+ return thresholds
923
+
924
+ # Tools classified as c3 vs native for adoption tracking
925
+ _C3_TOOLS = {"c3_search", "c3_compress", "c3_read", "c3_filter",
926
+ "c3_validate", "c3_session", "c3_memory", "c3_status",
927
+ "c3_delegate", "c3_edit", "c3_edits", "c3_agent"}
928
+ _NATIVE_TOOLS = {"Read", "Grep", "Glob", "Bash", "Edit", "Write",
929
+ "FindFiles", "SearchText"}
930
+ # C3 infra tools: their tokens are overhead, not content delivery
931
+ _C3_INFRA_TOOLS = {"c3_session", "c3_memory", "c3_status", "c3_edits"}
932
+
933
+ def track_response(self, tool_name: str, response_text: str,
934
+ response_tokens: int = 0) -> None:
935
+ """Count tokens on response, accumulate in budget.
936
+ Pass response_tokens to skip redundant count_tokens() call."""
937
+ if not self.current_session:
938
+ return
939
+ budget = self.current_session["context_budget"]
940
+ tokens = response_tokens or count_tokens(response_text)
941
+ budget["response_tokens"] += tokens
942
+ budget["call_count"] += 1
943
+ if tokens > budget["peak_tokens"]:
944
+ budget["peak_tokens"] = tokens
945
+ budget["by_tool"][tool_name] = budget["by_tool"].get(tool_name, 0) + tokens
946
+ # Track c3 vs native adoption
947
+ if tool_name in self._C3_TOOLS:
948
+ budget["c3_calls"] = budget.get("c3_calls", 0) + 1
949
+ elif tool_name in self._NATIVE_TOOLS:
950
+ budget["native_calls"] = budget.get("native_calls", 0) + 1
951
+ # Track infra overhead separately
952
+ if tool_name in self._C3_INFRA_TOOLS:
953
+ budget["infra_tokens"] = budget.get("infra_tokens", 0) + tokens
954
+ # Persist every 5 calls
955
+ if budget["call_count"] % 5 == 0:
956
+ self._persist_budget()
957
+
958
+ def _persist_budget(self) -> None:
959
+ """Write current budget snapshot to .c3/context_budget.json."""
960
+ if not self.current_session:
961
+ return
962
+ budget = self.current_session["context_budget"]
963
+ self._budget_file.parent.mkdir(parents=True, exist_ok=True)
964
+ try:
965
+ with open(self._budget_file, 'w', encoding='utf-8') as f:
966
+ json.dump(budget, f, indent=2)
967
+ except Exception:
968
+ pass
969
+
970
+ def mark_auto_snapshot_fired(self) -> None:
971
+ """Mark that auto-snapshot has been triggered for this session."""
972
+ if self.current_session:
973
+ self.current_session["context_budget"]["auto_snapshot_fired"] = True
974
+ self._persist_budget()
975
+
976
+ def get_context_nudge(self) -> str:
977
+ """Return a budget nudge if over threshold, else empty string."""
978
+ if not self.current_session:
979
+ return ""
980
+ budget = self.current_session["context_budget"]
981
+ total = budget["response_tokens"]
982
+ threshold = self._budget_thresholds["threshold"]
983
+ if total < threshold:
984
+ return ""
985
+ pct = round(total / threshold * 100) if threshold > 0 else 0
986
+ return (f"\n[ctx:{pct}%|high] Token budget exceeded threshold. "
987
+ "Run c3_session(action='compact') soon, then ask user to /clear and restore.")
988
+
989
+ def get_budget_snapshot(self) -> dict:
990
+ """Return budget stats for c3_status. Cached by call_count."""
991
+ if not self.current_session:
992
+ return {"error": "no active session"}
993
+ budget = self.current_session["context_budget"]
994
+ # Cache: reuse last snapshot if call_count hasn't changed
995
+ cc = budget["call_count"]
996
+ if hasattr(self, "_snap_cache") and self._snap_cache[0] == cc:
997
+ return self._snap_cache[1]
998
+ total = budget["response_tokens"]
999
+ infra = budget.get("infra_tokens", 0)
1000
+ content = total - infra
1001
+ calls = budget["call_count"]
1002
+ avg = round(total / calls) if calls > 0 else 0
1003
+ by_tool = budget.get("by_tool", {})
1004
+ top = sorted(by_tool.items(), key=lambda x: -x[1])[:5]
1005
+ c3 = budget.get("c3_calls", 0)
1006
+ native = budget.get("native_calls", 0)
1007
+ total_classified = c3 + native
1008
+ adoption_pct = round(c3 / total_classified * 100) if total_classified > 0 else 100
1009
+ snap = {
1010
+ "response_tokens": total,
1011
+ "content_tokens": content,
1012
+ "infra_tokens": infra,
1013
+ "call_count": calls,
1014
+ "peak_tokens": budget["peak_tokens"],
1015
+ "avg_tokens_per_call": avg,
1016
+ "top_consumers": [{"tool": t, "tokens": n} for t, n in top],
1017
+ "threshold": self._budget_thresholds["threshold"],
1018
+ "over_budget": total >= self._budget_thresholds["threshold"],
1019
+ "by_tool": by_tool,
1020
+ "c3_calls": c3,
1021
+ "native_calls": native,
1022
+ "c3_adoption_pct": adoption_pct,
1023
+ "auto_snapshot_fired": budget.get("auto_snapshot_fired", False),
1024
+ }
1025
+ self._snap_cache = (cc, snap)
1026
+ return snap