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,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
|