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
cli/server.py ADDED
@@ -0,0 +1,2985 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ C3 Web Server — Flask API + embedded UI
4
+
5
+ Serves both the API endpoints and the single-page React dashboard.
6
+ Launch with: c3 ui [--port 3333]
7
+ """
8
+ import atexit
9
+ import csv
10
+ import json
11
+ import logging
12
+ import os
13
+ import re
14
+ import signal
15
+ import subprocess
16
+ import sys
17
+ import threading
18
+ import time
19
+ from datetime import datetime, timezone
20
+ from pathlib import Path
21
+
22
+ from flask import Flask, Response, jsonify, request, send_file
23
+
24
+ # Add parent to path
25
+ sys.path.insert(0, str(Path(__file__).parent.parent))
26
+
27
+ from core import count_tokens
28
+ from core.config import load_delegate_config, load_mcp_config, load_proxy_config
29
+ from core.ide import PROFILES, detect_ide, get_profile, load_ide_config, normalize_ide_name
30
+ from services.protocol import CompressionProtocol
31
+ from services.runtime import build_runtime, stop_runtime
32
+ from services.session_manager import SessionManager
33
+
34
+ app = Flask(__name__)
35
+
36
+ # ─── Globals (set on startup) ────────────────────────────
37
+ PROJECT_PATH = None
38
+ compressor = None
39
+ indexer = None
40
+ session_mgr = None
41
+ protocol = None
42
+ memory_store = None
43
+ claude_md_mgr = None
44
+ activity_log = None
45
+ notification_store = None
46
+ vector_store = None
47
+ output_filter = None
48
+ router = None
49
+ metrics_collector = None
50
+ hybrid_config = None
51
+ watcher = None
52
+ file_memory = None
53
+ version_tracker = None
54
+ ollama_client = None
55
+ agents = []
56
+ runtime = None
57
+
58
+ # ─── Global session registry ─────────────────────────────
59
+ _GLOBAL_C3_DIR = Path.home() / ".c3"
60
+ _REGISTRY_FILE = _GLOBAL_C3_DIR / "registry.json"
61
+ _REGISTRY_LOCK = threading.Lock()
62
+ _HUB_CONFIG_FILE = _GLOBAL_C3_DIR / "hub_config.json"
63
+
64
+
65
+ def _registry_read() -> list:
66
+ try:
67
+ if _REGISTRY_FILE.exists():
68
+ with open(_REGISTRY_FILE, encoding="utf-8") as f:
69
+ return json.load(f)
70
+ except Exception:
71
+ pass
72
+ return []
73
+
74
+
75
+ def _registry_write(entries: list):
76
+ _GLOBAL_C3_DIR.mkdir(parents=True, exist_ok=True)
77
+ with open(_REGISTRY_FILE, "w", encoding="utf-8") as f:
78
+ json.dump(entries, f, indent=2)
79
+
80
+
81
+ def _read_hub_config() -> dict:
82
+ cfg = {
83
+ "port": 3330,
84
+ "auto_open_browser": True,
85
+ "theme": "dark",
86
+ "projects_view": "list",
87
+ }
88
+ try:
89
+ if _HUB_CONFIG_FILE.exists():
90
+ with open(_HUB_CONFIG_FILE, encoding="utf-8") as f:
91
+ cfg.update(json.load(f))
92
+ except Exception:
93
+ pass
94
+ return cfg
95
+
96
+
97
+ def _port_alive(port: int) -> bool:
98
+ import socket
99
+ try:
100
+ with socket.create_connection(("127.0.0.1", port), timeout=0.3):
101
+ return True
102
+ except Exception:
103
+ return False
104
+
105
+
106
+ def _register_session(port: int, project_path: str, project_name: str):
107
+ with _REGISTRY_LOCK:
108
+ entries = _registry_read()
109
+ # Remove our own port and any stale (dead) entries
110
+ entries = [e for e in entries if e.get("port") != port and _port_alive(e.get("port", 0))]
111
+ entries.append({
112
+ "port": port,
113
+ "project_path": project_path,
114
+ "project_name": project_name,
115
+ "started_at": time.time(),
116
+ })
117
+ _registry_write(entries)
118
+
119
+
120
+ def _unregister_session(port: int):
121
+ with _REGISTRY_LOCK:
122
+ entries = _registry_read()
123
+ entries = [e for e in entries if e.get("port") != port]
124
+ _registry_write(entries)
125
+
126
+
127
+ def init_services(project_path: str):
128
+ """Initialize all C3 services for a project."""
129
+ global PROJECT_PATH, compressor, indexer, session_mgr, protocol, memory_store, claude_md_mgr, activity_log, notification_store, vector_store, output_filter, router, metrics_collector, hybrid_config, watcher, file_memory, version_tracker, ollama_client, agents, runtime
130
+ runtime = build_runtime(project_path)
131
+ PROJECT_PATH = Path(runtime.project_path)
132
+ protocol = CompressionProtocol(str(PROJECT_PATH), str(PROJECT_PATH / ".c3" / "dictionary.json"))
133
+ compressor = runtime.compressor
134
+ indexer = runtime.indexer
135
+ session_mgr = runtime.session_mgr
136
+ memory_store = runtime.memory
137
+ claude_md_mgr = runtime.claude_md
138
+ activity_log = runtime.activity_log
139
+ notification_store = runtime.notifications
140
+ vector_store = runtime.vector_store
141
+ output_filter = runtime.output_filter
142
+ router = runtime.router
143
+ metrics_collector = runtime.metrics
144
+ hybrid_config = runtime.hybrid_config
145
+ watcher = runtime.watcher
146
+ file_memory = runtime.file_memory
147
+ version_tracker = runtime.version_tracker
148
+ ollama_client = runtime.ollama_client
149
+ agents = runtime.agents
150
+
151
+ if watcher:
152
+ watcher.start()
153
+ for agent in agents:
154
+ agent.start()
155
+
156
+
157
+ def _cleanup_runtime():
158
+ """Best-effort shutdown for long-lived background services."""
159
+ global agents, watcher, runtime
160
+ stop_runtime(runtime)
161
+ runtime = None
162
+ agents = []
163
+ watcher = None
164
+
165
+
166
+ atexit.register(_cleanup_runtime)
167
+
168
+
169
+ # ─── CORS middleware ──────────────────────────────────────
170
+ @app.after_request
171
+ def add_cors(response):
172
+ response.headers['Access-Control-Allow-Origin'] = '*'
173
+ response.headers['Access-Control-Allow-Headers'] = 'Content-Type'
174
+ response.headers['Access-Control-Allow-Methods'] = 'GET,POST,DELETE,OPTIONS'
175
+ return response
176
+
177
+
178
+ # ─── Serve the UI ─────────────────────────────────────────
179
+
180
+ # JS load order for concatenated UI build
181
+ _UI_JS_FILES = [
182
+ "ui/theme.js",
183
+ "ui/icons.js",
184
+ "ui/api.js",
185
+ "ui/shared.js",
186
+ "ui/components/sidebar.js",
187
+ "ui/components/dashboard.js",
188
+ "ui/components/sessions.js",
189
+ "ui/components/memory.js",
190
+ "ui/components/edits.js",
191
+ "ui/components/instructions.js",
192
+ "ui/components/settings.js",
193
+ "ui/components/chat.js",
194
+ "ui/app.js",
195
+ ]
196
+
197
+ def _build_ui_html() -> str:
198
+ """Concatenate ui.html shell + all JS component files into a single HTML response."""
199
+ cli_dir = Path(__file__).parent
200
+ shell_path = cli_dir / "ui.html"
201
+ if not shell_path.exists():
202
+ return "<h1>C3 UI not found. Run from the claude-companion directory.</h1>"
203
+
204
+ shell = shell_path.read_text(encoding="utf-8")
205
+
206
+ # Collect all JS files
207
+ js_parts = []
208
+ for rel in _UI_JS_FILES:
209
+ js_path = cli_dir / rel
210
+ if js_path.exists():
211
+ js_parts.append(f" // ═══ {rel} ═══\n" + js_path.read_text(encoding="utf-8"))
212
+
213
+ combined_js = "\n\n".join(js_parts)
214
+
215
+ # Inject into shell placeholder
216
+ return shell.replace("/* __C3_UI_SCRIPTS__ */", combined_js)
217
+
218
+
219
+ # Cache the built HTML (rebuilt on first request; cleared on server restart)
220
+ _ui_html_cache: str | None = None
221
+
222
+ @app.route('/')
223
+ def serve_ui():
224
+ global _ui_html_cache
225
+ if _ui_html_cache is None:
226
+ _ui_html_cache = _build_ui_html()
227
+ return Response(_ui_html_cache, mimetype='text/html')
228
+
229
+
230
+ @app.route('/legacy')
231
+ def serve_ui_legacy():
232
+ legacy_path = Path(__file__).parent / "ui_legacy.html"
233
+ if legacy_path.exists():
234
+ return send_file(str(legacy_path), mimetype='text/html')
235
+ return "<h1>Legacy UI not found.</h1>", 404
236
+
237
+
238
+ @app.route('/nano')
239
+ def serve_ui_nano():
240
+ nano_path = Path(__file__).parent / "ui_nano.html"
241
+ if nano_path.exists():
242
+ return send_file(str(nano_path), mimetype='text/html')
243
+ return "<h1>C3 Nano UI not found. Run from the claude-companion directory.</h1>", 404
244
+
245
+
246
+ @app.route('/docs')
247
+ def serve_docs():
248
+ docs_path = Path(__file__).parent / "docs.html"
249
+ if docs_path.exists():
250
+ return send_file(str(docs_path), mimetype='text/html')
251
+ return "<h1>C3 Docs not found.</h1>", 404
252
+
253
+
254
+ @app.route('/edits')
255
+ def serve_edits():
256
+ edits_path = Path(__file__).parent / "edits.html"
257
+ if edits_path.exists():
258
+ return send_file(str(edits_path), mimetype='text/html')
259
+ return "<h1>C3 Edit Ledger not found.</h1>", 404
260
+
261
+
262
+ @app.route('/api/hub/info')
263
+ def api_hub_info():
264
+ cfg = _read_hub_config()
265
+ port = int(cfg.get("port", 3330) or 3330)
266
+ return jsonify({
267
+ "port": port,
268
+ "url": f"http://localhost:{port}",
269
+ })
270
+
271
+
272
+ # ─── API: Health ─────────────────────────────────────────
273
+ @app.route('/api/projects/open', methods=['POST'])
274
+ def api_projects_open():
275
+ """Open a project directory in the OS file explorer. Body: {path}"""
276
+ try:
277
+ data = request.get_json(force=True) or {}
278
+ path_str = (data.get("path") or "").strip()
279
+ if not path_str:
280
+ return jsonify({"error": "path is required"}), 400
281
+
282
+ path = Path(path_str).resolve()
283
+ if not path.exists():
284
+ return jsonify({"error": f"Path does not exist: {path_str}"}), 404
285
+
286
+ if sys.platform == "win32":
287
+ os.startfile(str(path))
288
+ elif sys.platform == "darwin":
289
+ subprocess.run(["open", str(path)], check=True)
290
+ else:
291
+ subprocess.run(["xdg-open", str(path)], check=True)
292
+ return jsonify({"opened": True})
293
+ except Exception as e:
294
+ return jsonify({"error": f"Failed to open folder: {str(e)}"}), 500
295
+
296
+
297
+ @app.route('/api/health')
298
+ def api_health():
299
+ """Lightweight health check with connection sources."""
300
+ sources = {"c3": True} # If this endpoint responds, C3 API is up
301
+
302
+ # Check Ollama
303
+ try:
304
+ from services.ollama_client import OllamaClient
305
+ base_url = (hybrid_config or {}).get("ollama_base_url", "http://localhost:11434")
306
+ client = OllamaClient(base_url)
307
+ models = client.list_models()
308
+ sources["ollama"] = models is not None
309
+ except Exception:
310
+ sources["ollama"] = False
311
+
312
+ # Check proxy connected: config references mcp_proxy.py OR state file is recent
313
+ try:
314
+ import time as _time
315
+ proxy_connected = False
316
+
317
+ # Primary: config check — MCP config must reference mcp_proxy.py
318
+ try:
319
+ ide_name = load_ide_config(str(PROJECT_PATH))
320
+ profile = get_profile(ide_name)
321
+ mcp_cfg_path = _mcp_config_path_for_profile(profile)
322
+ if mcp_cfg_path.exists():
323
+ with open(mcp_cfg_path, encoding="utf-8") as f:
324
+ content = f.read()
325
+ if "mcp_proxy.py" in content:
326
+ proxy_connected = True
327
+ except Exception:
328
+ pass
329
+
330
+ # Secondary: proxy_state.json written within last 4 hours means proxy ran recently
331
+ if not proxy_connected:
332
+ state_file = PROJECT_PATH / ".c3" / "proxy_state.json"
333
+ if state_file.exists():
334
+ try:
335
+ with open(state_file, encoding="utf-8") as f:
336
+ pstate = json.load(f)
337
+ ts = pstate.get("last_updated")
338
+ if ts and (_time.time() - float(ts)) < 4 * 3600:
339
+ proxy_connected = True
340
+ except Exception:
341
+ # Fall back to file mtime
342
+ age = _time.time() - state_file.stat().st_mtime
343
+ if age < 4 * 3600:
344
+ proxy_connected = True
345
+
346
+ sources["proxy"] = proxy_connected
347
+ sources["mcp_mode"] = load_mcp_config(str(PROJECT_PATH)).get("mode", "direct")
348
+ except Exception:
349
+ sources["proxy"] = False
350
+ sources["mcp_mode"] = "direct"
351
+
352
+ # Check SLTM
353
+ sources["sltm"] = vector_store is not None
354
+
355
+ # Minimal session info
356
+ session_info = None
357
+ try:
358
+ current = session_mgr.current_session
359
+ if current:
360
+ session_info = {
361
+ "tool_calls": len(current.get("tool_calls", [])),
362
+ "started": current.get("started"),
363
+ }
364
+ except Exception:
365
+ pass
366
+
367
+ return jsonify({"service": "c3-ui", "sources": sources, "session": session_info})
368
+
369
+
370
+ # ─── API: Session Registry ───────────────────────────────
371
+ @app.route('/api/registry')
372
+ def api_registry():
373
+ """Return all live C3 sessions from the global registry."""
374
+ with _REGISTRY_LOCK:
375
+ entries = _registry_read()
376
+ live = [e for e in entries if _port_alive(e.get("port", 0))]
377
+ return jsonify(live)
378
+
379
+
380
+ # ─── API: Stats & Overview ───────────────────────────────
381
+ @app.route('/api/stats')
382
+ def api_stats():
383
+ """Get comprehensive system stats."""
384
+ idx_stats = indexer.get_stats()
385
+ proto_stats = protocol.get_stats()
386
+
387
+ # Calculate compression stats across all indexed files
388
+ skip_dirs = indexer.skip_dirs
389
+ code_exts = indexer.code_exts
390
+
391
+ files_data = []
392
+ total_orig = 0
393
+ total_comp = 0
394
+
395
+ for fpath in sorted(PROJECT_PATH.rglob('*')):
396
+ if len(files_data) >= 100:
397
+ break
398
+ if not fpath.is_file():
399
+ continue
400
+ if fpath.suffix.lower() not in code_exts:
401
+ continue
402
+ if any(skip in fpath.parts for skip in skip_dirs):
403
+ continue
404
+ if compressor.is_protected_file(fpath):
405
+ continue
406
+ try:
407
+ content = fpath.read_text(encoding='utf-8', errors='replace')
408
+ orig_tokens = count_tokens(content)
409
+ result = compressor.compress_file(str(fpath), "smart")
410
+ comp_tokens = result.get("compressed_tokens", orig_tokens)
411
+
412
+ rel_path = str(fpath.relative_to(PROJECT_PATH))
413
+ files_data.append({
414
+ "name": fpath.name,
415
+ "path": rel_path,
416
+ "lines": len(content.splitlines()),
417
+ "origTokens": orig_tokens,
418
+ "compTokens": comp_tokens,
419
+ "type": fpath.suffix.lstrip('.').lower(),
420
+ })
421
+ total_orig += orig_tokens
422
+ total_comp += comp_tokens
423
+ except Exception:
424
+ continue
425
+
426
+ # Session stats
427
+ sessions = session_mgr.list_sessions(20)
428
+ total_decisions = sum(s.get("decisions", 0) for s in sessions)
429
+
430
+ savings_pct = round(((total_orig - total_comp) / total_orig * 100), 1) if total_orig > 0 else 0
431
+
432
+ # Total lines of code
433
+ total_lines = sum(f["lines"] for f in files_data)
434
+
435
+ # Tech stack detection from file extensions
436
+ ext_counts = {}
437
+ for f in files_data:
438
+ ext_counts[f["type"]] = ext_counts.get(f["type"], 0) + 1
439
+ tech_stack = ", ".join(
440
+ ext for ext, _ in sorted(ext_counts.items(), key=lambda x: -x[1])
441
+ ) if ext_counts else "Unknown"
442
+
443
+ # Last session date
444
+ last_session = sessions[0].get("started", "") if sessions else None
445
+
446
+ # Total tool calls across sessions
447
+ total_tool_calls = sum(s.get("tool_calls", 0) for s in sessions)
448
+
449
+ # Claude Code token usage (input/output from Claude's own session logs)
450
+ try:
451
+ claude_tokens = session_mgr.parse_claude_session_tokens()
452
+ except Exception:
453
+ claude_tokens = {"sessions_found": 0, "total_input_tokens": 0, "total_output_tokens": 0}
454
+
455
+ # Per-session and per-source token usage from conversation store
456
+ conversation_token_usage = {"sessions": [], "sources": {}, "totals": {"user_tokens": 0, "assistant_tokens": 0, "total_tokens": 0}}
457
+ try:
458
+ conv_store = _get_conv_store()
459
+ conv_sessions = conv_store.list_sessions(limit=1000)
460
+ source_totals = {}
461
+ session_rows = []
462
+ for s in conv_sessions:
463
+ user_tok = int(s.get("user_tokens", 0) or 0)
464
+ asst_tok = int(s.get("assistant_tokens", 0) or 0)
465
+ total_tok = user_tok + asst_tok
466
+ source = (s.get("source") or "manual").strip().lower()
467
+ if not source:
468
+ source = "manual"
469
+ if source not in source_totals:
470
+ source_totals[source] = {"sessions": 0, "user_tokens": 0, "assistant_tokens": 0, "total_tokens": 0}
471
+ source_totals[source]["sessions"] += 1
472
+ source_totals[source]["user_tokens"] += user_tok
473
+ source_totals[source]["assistant_tokens"] += asst_tok
474
+ source_totals[source]["total_tokens"] += total_tok
475
+ conversation_token_usage["totals"]["user_tokens"] += user_tok
476
+ conversation_token_usage["totals"]["assistant_tokens"] += asst_tok
477
+ conversation_token_usage["totals"]["total_tokens"] += total_tok
478
+ session_rows.append({
479
+ "session_id": s.get("session_id", ""),
480
+ "source": source,
481
+ "started": s.get("started"),
482
+ "ended": s.get("ended"),
483
+ "turns": int(s.get("turns", 0) or 0),
484
+ "user_tokens": user_tok,
485
+ "assistant_tokens": asst_tok,
486
+ "total_tokens": total_tok,
487
+ })
488
+
489
+ session_rows.sort(key=lambda x: x.get("started", 0), reverse=True)
490
+ conversation_token_usage["sessions"] = session_rows
491
+ conversation_token_usage["sources"] = source_totals
492
+ except Exception:
493
+ pass
494
+
495
+ # Context budget from MCP session
496
+ context_budget = None
497
+ budget_file = PROJECT_PATH / ".c3" / "context_budget.json"
498
+ if budget_file.exists():
499
+ try:
500
+ with open(budget_file, encoding='utf-8') as f:
501
+ context_budget = json.load(f)
502
+ except Exception:
503
+ pass
504
+
505
+ return jsonify({
506
+ "project_path": str(PROJECT_PATH),
507
+ "index": idx_stats,
508
+ "protocol": proto_stats,
509
+ "files": files_data,
510
+ "total_original_tokens": total_orig,
511
+ "total_compressed_tokens": total_comp,
512
+ "savings_pct": savings_pct,
513
+ "sessions_count": len(sessions),
514
+ "total_decisions": total_decisions,
515
+ "total_tool_calls": total_tool_calls,
516
+ "total_lines": total_lines,
517
+ "tech_stack": tech_stack,
518
+ "last_session": last_session,
519
+ "claude_tokens": claude_tokens,
520
+ "conversation_token_usage": conversation_token_usage,
521
+ "context_budget": context_budget,
522
+ })
523
+
524
+
525
+ # ─── API: Claude Usage ───────────────────────────────────
526
+ @app.route('/api/claude_usage', methods=['GET'])
527
+ def api_claude_usage():
528
+ """Detailed Claude Code token usage with per-session breakdown and global time-window stats."""
529
+ try:
530
+ data = session_mgr.parse_claude_session_tokens(detailed=True)
531
+ except Exception:
532
+ data = {"sessions_found": 0, "total_input_tokens": 0, "total_output_tokens": 0,
533
+ "cache_creation_tokens": 0, "cache_read_tokens": 0, "sessions": []}
534
+ try:
535
+ # Accept optional query params so the UI can pass the user's configured reset schedule
536
+ wd = request.args.get("weekly_reset_weekday", 4, type=int) # 4=Friday
537
+ wh = request.args.get("weekly_reset_hour_utc", 22, type=int) # 22=6PM ET
538
+ data["global_windows"] = _compute_global_usage_windows(wd, wh)
539
+ except Exception:
540
+ data["global_windows"] = {}
541
+ return jsonify(data)
542
+
543
+
544
+ @app.route('/api/session-stats', methods=['GET'])
545
+ def api_session_stats():
546
+ """Session token/cost stats captured by the Stop hook (.c3/session_stats.jsonl)."""
547
+ limit = request.args.get("limit", 50, type=int)
548
+ try:
549
+ entries = session_mgr.get_session_stats(limit=limit)
550
+ except Exception:
551
+ entries = []
552
+ total_cost = sum(e.get("cost_usd") or 0 for e in entries)
553
+ total_in = sum(e.get("input_tokens") or 0 for e in entries)
554
+ total_out = sum(e.get("output_tokens") or 0 for e in entries)
555
+ total_cache = sum(e.get("cache_read_tokens") or 0 for e in entries)
556
+ return jsonify({
557
+ "sessions": entries,
558
+ "total_sessions": len(entries),
559
+ "total_cost_usd": round(total_cost, 6),
560
+ "total_input_tokens": total_in,
561
+ "total_output_tokens": total_out,
562
+ "total_cache_read_tokens": total_cache,
563
+ })
564
+
565
+
566
+ @app.route('/api/session-stats/live', methods=['GET'])
567
+ def api_live_session_tokens():
568
+ """Live token counts from the most recently modified Claude Code transcript file.
569
+
570
+ Claude Code appends to transcript JSONL after each response, so polling this
571
+ endpoint gives per-turn live visibility into the active session's token usage.
572
+ """
573
+ try:
574
+ data = session_mgr.get_live_session_tokens()
575
+ except Exception:
576
+ data = {}
577
+ return jsonify(data)
578
+
579
+
580
+ def _last_weekly_reset(now, reset_weekday: int, reset_hour: int):
581
+ """Return the most recent occurrence of weekday/hour in UTC.
582
+
583
+ reset_weekday: 0=Mon … 6=Sun (default 4=Fri)
584
+ reset_hour: hour in UTC (default 22 = Fri 6PM US-Eastern / UTC-4)
585
+ """
586
+ from datetime import timedelta
587
+ # Walk back from now to find the previous reset
588
+ candidate = now.replace(minute=0, second=0, microsecond=0, hour=reset_hour)
589
+ for days_back in range(8):
590
+ t = candidate - timedelta(days=days_back)
591
+ if t.weekday() == reset_weekday and t <= now:
592
+ return t
593
+ return now - timedelta(days=7)
594
+
595
+
596
+ def _compute_global_usage_windows(weekly_reset_weekday: int = 4, weekly_reset_hour_utc: int = 22):
597
+ """Scan ALL ~/.claude/projects JSONL files and compute usage windows.
598
+
599
+ 5-hour rolling window (matching Claude.ai 'Current Session').
600
+ Weekly window since the last fixed reset day/time (matching Claude.ai 'This Week').
601
+
602
+ weekly_reset_weekday: 0=Mon … 6=Sun, default 4 (Friday)
603
+ weekly_reset_hour_utc: UTC hour of the weekly reset, default 22 (= 6 PM US Eastern / UTC-4)
604
+ """
605
+ from datetime import datetime, timedelta, timezone
606
+
607
+ home = Path.home()
608
+ projects_dir = home / ".claude" / "projects"
609
+ if not projects_dir.exists():
610
+ return {}
611
+
612
+ now = datetime.now(timezone.utc)
613
+ window_5h = now - timedelta(hours=5)
614
+
615
+ # Weekly window: from the last scheduled reset (fixed day/time), not rolling
616
+ last_reset = _last_weekly_reset(now, weekly_reset_weekday, weekly_reset_hour_utc)
617
+ next_reset = last_reset + timedelta(days=7)
618
+ window_weekly = last_reset
619
+
620
+ # Read subscription/plan info from credentials
621
+ cred_file = home / ".claude" / ".credentials.json"
622
+ subscription_type = "unknown"
623
+ try:
624
+ with open(cred_file, encoding='utf-8') as f:
625
+ cred = json.load(f)
626
+ subscription_type = cred.get("claudeAiOauth", {}).get("subscriptionType", "unknown")
627
+ except Exception:
628
+ pass
629
+
630
+ # Token-based limits per plan.
631
+ # Derived empirically: Pro 5h ≈ 42M tokens, 7d ≈ 1.43B tokens.
632
+ # These match the % values shown on claude.ai/settings (usage is input+cache+output tokens).
633
+ PLAN_LIMITS = {
634
+ "pro": {"session_5h_tokens": 42_000_000, "weekly_tokens": 1_430_000_000},
635
+ "max": {"session_5h_tokens": 140_000_000, "weekly_tokens": 5_000_000_000},
636
+ "unknown": {"session_5h_tokens": 42_000_000, "weekly_tokens": 1_430_000_000},
637
+ }
638
+ limits = PLAN_LIMITS.get(subscription_type, PLAN_LIMITS["unknown"])
639
+
640
+ # Per-window accumulators
641
+ sess_messages = 0
642
+ sess_tokens = 0 # total tokens (in + out) for % calculation
643
+ sess_input_tokens = 0
644
+ sess_output_tokens = 0
645
+ sess_window_start = None # earliest assistant entry in current 5h window
646
+
647
+ week_messages = 0
648
+ week_tokens = 0
649
+ week_input_tokens = 0
650
+ week_output_tokens = 0
651
+ week_window_start = None
652
+
653
+ for project_dir in projects_dir.iterdir():
654
+ if not project_dir.is_dir():
655
+ continue
656
+ for session_file in project_dir.glob("*.jsonl"):
657
+ try:
658
+ with open(session_file, encoding="utf-8", errors="replace") as f:
659
+ for line in f:
660
+ line = line.strip()
661
+ if not line:
662
+ continue
663
+ entry = json.loads(line)
664
+ ts_str = entry.get("timestamp")
665
+ if not ts_str:
666
+ continue
667
+ try:
668
+ ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
669
+ except Exception:
670
+ continue
671
+
672
+ # Count assistant turns — each = one API call with usage data
673
+ if entry.get("type") != "assistant":
674
+ continue
675
+
676
+ msg = entry.get("message", {})
677
+ usage = msg.get("usage", {})
678
+ inp = (usage.get("input_tokens", 0)
679
+ + usage.get("cache_creation_input_tokens", 0)
680
+ + usage.get("cache_read_input_tokens", 0))
681
+ out = usage.get("output_tokens", 0)
682
+ total_tok = inp + out
683
+
684
+ if ts >= window_weekly:
685
+ week_messages += 1
686
+ week_input_tokens += inp
687
+ week_output_tokens += out
688
+ week_tokens += total_tok
689
+ if week_window_start is None or ts < week_window_start:
690
+ week_window_start = ts
691
+
692
+ if ts >= window_5h:
693
+ sess_messages += 1
694
+ sess_input_tokens += inp
695
+ sess_output_tokens += out
696
+ sess_tokens += total_tok
697
+ if sess_window_start is None or ts < sess_window_start:
698
+ sess_window_start = ts
699
+ except Exception:
700
+ continue
701
+
702
+ # Compute reset times (5h rolling from first message, 7d rolling from first message)
703
+ sess_reset_at = None
704
+ sess_resets_in_s = None
705
+ if sess_window_start:
706
+ sess_reset_at = (sess_window_start + timedelta(hours=5)).isoformat()
707
+ sess_resets_in_s = max(0, int((sess_window_start + timedelta(hours=5) - now).total_seconds()))
708
+
709
+ # Weekly resets at the fixed schedule (next_reset), regardless of when first message was
710
+ week_reset_at = next_reset.isoformat()
711
+ week_resets_in_s = max(0, int((next_reset - now).total_seconds()))
712
+
713
+ # Percentage based on token usage vs plan token limits
714
+ sess_pct = min(100, round(sess_tokens / limits["session_5h_tokens"] * 100)) if limits["session_5h_tokens"] else 0
715
+ week_pct = min(100, round(week_tokens / limits["weekly_tokens"] * 100)) if limits["weekly_tokens"] else 0
716
+
717
+ def _fmt_tokens(n):
718
+ if n >= 1_000_000:
719
+ return f"{n / 1_000_000:.1f}M"
720
+ if n >= 1_000:
721
+ return f"{n / 1_000:.1f}K"
722
+ return str(n)
723
+
724
+ return {
725
+ "subscription_type": subscription_type,
726
+ "limits": {
727
+ "session_5h_tokens": limits["session_5h_tokens"],
728
+ "weekly_tokens": limits["weekly_tokens"],
729
+ },
730
+ "session_5h": {
731
+ "messages": sess_messages,
732
+ "input_tokens": sess_input_tokens,
733
+ "output_tokens": sess_output_tokens,
734
+ "total_tokens": sess_tokens,
735
+ "total_tokens_fmt": _fmt_tokens(sess_tokens),
736
+ "limit_tokens_fmt": _fmt_tokens(limits["session_5h_tokens"]),
737
+ "pct_used": sess_pct,
738
+ "reset_at": sess_reset_at,
739
+ "resets_in_seconds": sess_resets_in_s,
740
+ "window_start": sess_window_start.isoformat() if sess_window_start else None,
741
+ },
742
+ "weekly": {
743
+ "messages": week_messages,
744
+ "input_tokens": week_input_tokens,
745
+ "output_tokens": week_output_tokens,
746
+ "total_tokens": week_tokens,
747
+ "total_tokens_fmt": _fmt_tokens(week_tokens),
748
+ "limit_tokens_fmt": _fmt_tokens(limits["weekly_tokens"]),
749
+ "pct_used": week_pct,
750
+ "reset_at": week_reset_at,
751
+ "resets_in_seconds": week_resets_in_s,
752
+ "window_start": week_window_start.isoformat() if week_window_start else None,
753
+ },
754
+ }
755
+
756
+
757
+ # ─── API: Compress ───────────────────────────────────────
758
+ @app.route('/api/compress', methods=['POST'])
759
+ def api_compress():
760
+ """Compress a file and return results."""
761
+ data = request.get_json() or {}
762
+ filepath = data.get("file", "")
763
+ mode = data.get("mode", "smart")
764
+
765
+ if not filepath:
766
+ return jsonify({"error": "No file specified"}), 400
767
+
768
+ # Resolve relative paths against project
769
+ full_path = (PROJECT_PATH / filepath).resolve()
770
+ if not full_path.exists():
771
+ return jsonify({"error": f"File not found: {filepath}"}), 404
772
+ if compressor.is_protected_file(full_path):
773
+ return jsonify({
774
+ "error": f"Compression is blocked for protected file: {filepath}",
775
+ "protected_files": compressor.get_protected_files(),
776
+ }), 403
777
+
778
+ result = compressor.compress_file(str(full_path), mode)
779
+ if "error" in result:
780
+ return jsonify(result), 400
781
+
782
+ return jsonify(result)
783
+
784
+
785
+ # ─── API: Batch compress ─────────────────────────────────
786
+ @app.route('/api/compress/batch', methods=['POST'])
787
+ def api_compress_batch():
788
+ data = request.get_json() or {}
789
+ mode = data.get("mode", "smart")
790
+ result = compressor.compress_directory(str(PROJECT_PATH), mode)
791
+ if "error" in result:
792
+ return jsonify(result), 400
793
+ return jsonify(result)
794
+
795
+
796
+ # ─── API: List files ─────────────────────────────────────
797
+ @app.route('/api/compress/protected-files')
798
+ def api_compress_protected_files():
799
+ """List files that cannot be compressed."""
800
+ return jsonify({"protected_files": compressor.get_protected_files()})
801
+
802
+
803
+ @app.route('/api/files')
804
+ def api_files():
805
+ """List project files available for compression."""
806
+ skip_dirs = indexer.skip_dirs
807
+ code_exts = indexer.code_exts
808
+
809
+ files = []
810
+ for fpath in sorted(PROJECT_PATH.rglob('*')):
811
+ if len(files) >= 200:
812
+ break
813
+ if not fpath.is_file():
814
+ continue
815
+ if fpath.suffix.lower() not in code_exts:
816
+ continue
817
+ if any(skip in fpath.parts for skip in skip_dirs):
818
+ continue
819
+ if compressor.is_protected_file(fpath):
820
+ continue
821
+ try:
822
+ content = fpath.read_text(encoding='utf-8', errors='replace')
823
+ files.append({
824
+ "name": fpath.name,
825
+ "path": str(fpath.relative_to(PROJECT_PATH)),
826
+ "lines": len(content.splitlines()),
827
+ "tokens": count_tokens(content),
828
+ "type": fpath.suffix.lstrip('.').lower(),
829
+ })
830
+ except Exception:
831
+ continue
832
+
833
+ return jsonify(files)
834
+
835
+
836
+ # ─── API: Search Index ───────────────────────────────────
837
+ @app.route('/api/search', methods=['POST'])
838
+ def api_search():
839
+ """Search the code index."""
840
+ data = request.get_json() or {}
841
+ query = data.get("query", "")
842
+ top_k = max(1, min(int(data.get("top_k", 3)), 10))
843
+ max_tokens = max(200, int(data.get("max_tokens", 1200)))
844
+
845
+ if not query:
846
+ return jsonify({"error": "No query specified"}), 400
847
+
848
+ results = indexer.search(query, top_k=top_k, max_tokens=max_tokens)
849
+ context = indexer.get_context(query, top_k=top_k, max_tokens=max_tokens)
850
+ context_tokens = count_tokens(context)
851
+
852
+ return jsonify({
853
+ "results": results,
854
+ "context": context,
855
+ "context_tokens": context_tokens,
856
+ "query": query,
857
+ })
858
+
859
+
860
+ # ─── API: Rebuild Index ──────────────────────────────────
861
+ @app.route('/api/index/rebuild', methods=['POST'])
862
+ def api_rebuild_index():
863
+ """Rebuild the code index."""
864
+ result = indexer.build_index()
865
+ return jsonify(result)
866
+
867
+
868
+ @app.route('/api/index/stats')
869
+ def api_index_stats():
870
+ """Get index statistics."""
871
+ return jsonify(indexer.get_stats())
872
+
873
+
874
+ # ─── API: Encode / Decode ────────────────────────────────
875
+ @app.route('/api/encode', methods=['POST'])
876
+ def api_encode():
877
+ """Encode text using compression protocol."""
878
+ data = request.get_json() or {}
879
+ text = data.get("text", "")
880
+ if not text:
881
+ return jsonify({"error": "No text specified"}), 400
882
+ result = protocol.encode(text)
883
+ return jsonify(result)
884
+
885
+
886
+ @app.route('/api/decode', methods=['POST'])
887
+ def api_decode():
888
+ """Decode compressed text."""
889
+ data = request.get_json() or {}
890
+ text = data.get("text", "")
891
+ if not text:
892
+ return jsonify({"error": "No text specified"}), 400
893
+ decoded = protocol.decode(text)
894
+ return jsonify({"decoded": decoded, "original": text})
895
+
896
+
897
+ @app.route('/api/protocol/header')
898
+ def api_protocol_header():
899
+ """Get the protocol header for system prompts."""
900
+ return jsonify({"header": protocol.get_protocol_header()})
901
+
902
+
903
+ @app.route('/api/protocol/dictionary')
904
+ def api_protocol_dictionary():
905
+ """Get the full compression dictionary."""
906
+ from services.protocol import ACTION_CODES, TERM_CODES
907
+ return jsonify({
908
+ "actions": {k: v for k, v in ACTION_CODES.items() if v},
909
+ "terms": TERM_CODES,
910
+ "custom": protocol.custom_dict,
911
+ })
912
+
913
+
914
+ @app.route('/api/protocol/build-dictionary', methods=['POST'])
915
+ def api_build_dictionary():
916
+ """Build project-specific dictionary."""
917
+ new_terms = protocol.build_project_dictionary()
918
+ return jsonify({"new_terms": new_terms, "total": len(protocol.custom_dict)})
919
+
920
+
921
+ # ─── API: Sessions ───────────────────────────────────────
922
+ @app.route('/api/sessions')
923
+ def api_sessions():
924
+ """List all sessions, backfilling tool call counts from activity log."""
925
+ sessions = session_mgr.list_sessions(50)
926
+ for s in sessions:
927
+ if s.get("tool_calls", 0) == 0 and s.get("started"):
928
+ count = len(activity_log.get_recent(
929
+ limit=500, event_type="tool_call",
930
+ since=s["started"], until=s.get("ended") or None,
931
+ ))
932
+ s["tool_calls"] = count
933
+ return jsonify(sessions)
934
+
935
+
936
+ @app.route('/api/sessions/current')
937
+ def api_session_current():
938
+ """Get the currently running MCP session by reading the activity log.
939
+
940
+ Finds the most recent session_start event, then gathers all activity
941
+ since that timestamp to construct a live current-session view.
942
+ """
943
+ # Find the most recent session_start event
944
+ starts = activity_log.get_recent(limit=1, event_type="session_start")
945
+ if not starts:
946
+ return jsonify(None)
947
+
948
+ start_event = starts[0]
949
+ session_id = start_event.get("session_id", "")
950
+ started = start_event.get("timestamp", "")
951
+ description = start_event.get("description", "")
952
+ current = session_mgr.current_session or {}
953
+ source_system = start_event.get("source_system", "") or current.get("source_system", "")
954
+ source_ide = start_event.get("source_ide", "") or current.get("source_ide", "")
955
+
956
+ # Check if this session was already saved (ended)
957
+ saves = activity_log.get_recent(limit=1, event_type="session_save")
958
+ if saves and saves[0].get("session_id") == session_id:
959
+ # Session is saved/ended — load from disk instead
960
+ saved = session_mgr.load_session(session_id)
961
+ if saved:
962
+ return jsonify(saved)
963
+
964
+ # Session is still running — build live view from activity log
965
+ events = activity_log.get_recent(limit=500, since=started)
966
+ events.reverse() # chronological order
967
+
968
+ tool_calls = []
969
+ decisions = []
970
+ files_touched = []
971
+ for ev in events:
972
+ t = ev.get("type")
973
+ if t == "tool_call":
974
+ tool_calls.append({
975
+ "tool": ev.get("tool", "unknown"),
976
+ "args": ev.get("args", {}),
977
+ "result_summary": ev.get("result_summary", ""),
978
+ "timestamp": ev.get("timestamp", ""),
979
+ })
980
+ elif t == "decision":
981
+ decisions.append({
982
+ "decision": ev.get("data", ev.get("decision", "")),
983
+ "reasoning": ev.get("reasoning", ""),
984
+ "timestamp": ev.get("timestamp", ""),
985
+ })
986
+ elif t == "file_change":
987
+ files_touched.append({
988
+ "file": ev.get("file", ev.get("data", "")),
989
+ "action": ev.get("action", "modified"),
990
+ "timestamp": ev.get("timestamp", ""),
991
+ })
992
+
993
+ now = datetime.now(timezone.utc)
994
+ started_dt = datetime.fromisoformat(started)
995
+ duration_seconds = int((now - started_dt).total_seconds())
996
+ hours, remainder = divmod(duration_seconds, 3600)
997
+ minutes, secs = divmod(remainder, 60)
998
+ duration = f"{hours}h {minutes}m" if hours else f"{minutes}m {secs}s"
999
+
1000
+ return jsonify({
1001
+ "id": session_id,
1002
+ "started": started,
1003
+ "ended": None,
1004
+ "description": description,
1005
+ "source_system": source_system,
1006
+ "source_ide": source_ide,
1007
+ "duration_seconds": duration_seconds,
1008
+ "duration": duration,
1009
+ "tool_calls": tool_calls,
1010
+ "decisions": decisions,
1011
+ "files_touched": files_touched,
1012
+ "context_notes": [],
1013
+ "live": True,
1014
+ })
1015
+
1016
+
1017
+ @app.route('/api/sessions/<session_id>')
1018
+ def api_session_detail(session_id):
1019
+ """Get session details."""
1020
+ session = session_mgr.load_session(session_id)
1021
+ return jsonify(session)
1022
+
1023
+
1024
+ @app.route('/api/sessions/start', methods=['POST'])
1025
+ def api_session_start():
1026
+ """Start a new session."""
1027
+ data = request.get_json(silent=True) or {}
1028
+ desc = data.get("description", "")
1029
+ source_system = data.get("source_system")
1030
+ result = session_mgr.start_session(desc, source_system=source_system)
1031
+ activity_log.log("session_start", {
1032
+ "session_id": result["session_id"],
1033
+ "description": desc,
1034
+ "source_system": result.get("source_system", ""),
1035
+ "source_ide": result.get("source_ide", ""),
1036
+ })
1037
+ return jsonify(result)
1038
+
1039
+
1040
+ @app.route('/api/sessions/save', methods=['POST'])
1041
+ def api_session_save():
1042
+ """Save current session."""
1043
+ data = request.get_json() or {}
1044
+ summary = data.get("summary", "")
1045
+ result = session_mgr.save_session(summary)
1046
+ return jsonify(result)
1047
+
1048
+
1049
+ @app.route('/api/auto-snapshot', methods=['POST'])
1050
+ def api_auto_snapshot():
1051
+ """Auto-snapshot triggered by the Stop hook on session end / Ctrl+C."""
1052
+ try:
1053
+ data = request.get_json() or {}
1054
+ # Flush auto-memory before capturing
1055
+ if hasattr(runtime, "auto_memory") and runtime.auto_memory:
1056
+ try:
1057
+ runtime.auto_memory.on_session_end()
1058
+ except Exception:
1059
+ pass
1060
+ # Capture snapshot with whatever session state is live
1061
+ snap_compressor = getattr(runtime, "compressor", None)
1062
+ result = runtime.snapshots.capture(
1063
+ session_mgr,
1064
+ memory_store,
1065
+ task_description=data.get("task_description", "auto-snapshot on stop"),
1066
+ compressor=snap_compressor,
1067
+ )
1068
+ session_mgr.mark_auto_snapshot_fired()
1069
+ return jsonify({"ok": True, "snapshot_id": result["snapshot_id"]})
1070
+ except Exception as exc:
1071
+ return jsonify({"ok": False, "error": str(exc)}), 500
1072
+
1073
+
1074
+ @app.route('/api/sessions/context')
1075
+ def api_session_context():
1076
+ """Get compressed context from recent sessions."""
1077
+ n = request.args.get('n', 3, type=int)
1078
+ context = session_mgr.get_session_context(n_sessions=n)
1079
+ return jsonify({"context": context, "tokens": count_tokens(context)})
1080
+
1081
+
1082
+ # ─── API: CLAUDE.md ──────────────────────────────────────
1083
+ @app.route('/api/claudemd')
1084
+ def api_claudemd():
1085
+ """Get generated CLAUDE.md content."""
1086
+ content = session_mgr.generate_claude_md()
1087
+ return jsonify({"content": content, "tokens": count_tokens(content)})
1088
+
1089
+
1090
+ @app.route('/api/claudemd/save', methods=['POST'])
1091
+ def api_claudemd_save():
1092
+ """Generate and save instructions file to project root using ClaudeMdManager."""
1093
+ gen = claude_md_mgr.generate(include_sessions=True)
1094
+ content = gen.get("content", "")
1095
+ if not content:
1096
+ return jsonify({"error": "Generation produced empty content"}), 500
1097
+
1098
+ output_path = PROJECT_PATH / claude_md_mgr.instructions_file
1099
+ output_path.parent.mkdir(parents=True, exist_ok=True)
1100
+
1101
+ # Preserve user-written sections
1102
+ if output_path.exists():
1103
+ existing = output_path.read_text(encoding="utf-8", errors="replace")
1104
+ if "# User Notes" in existing:
1105
+ user_section = existing[existing.index("# User Notes"):]
1106
+ content += f"\n\n{user_section}"
1107
+
1108
+ output_path.write_text(content, encoding="utf-8")
1109
+ return jsonify({
1110
+ "path": str(output_path),
1111
+ "tokens": gen.get("tokens", 0),
1112
+ "lines": gen.get("lines", 0),
1113
+ "status": "saved",
1114
+ })
1115
+
1116
+
1117
+ @app.route('/api/claudemd/check')
1118
+ def api_claudemd_check():
1119
+ """Check CLAUDE.md for staleness and drift."""
1120
+ result = claude_md_mgr.check_staleness()
1121
+ return jsonify(result)
1122
+
1123
+
1124
+ @app.route('/api/claudemd/compact', methods=['POST'])
1125
+ def api_claudemd_compact():
1126
+ """Compact CLAUDE.md to target line count."""
1127
+ data = request.get_json(silent=True) or {}
1128
+ target_lines = data.get('target_lines', 150)
1129
+ result = claude_md_mgr.compact(target_lines=target_lines)
1130
+ return jsonify(result)
1131
+
1132
+
1133
+ @app.route('/api/claudemd/promote')
1134
+ def api_claudemd_promote():
1135
+ """Get promotion candidates for CLAUDE.md."""
1136
+ min_relevance = request.args.get('min_relevance', 2, type=int)
1137
+ result = claude_md_mgr.get_promotion_candidates(min_relevance=min_relevance)
1138
+ return jsonify(result)
1139
+
1140
+
1141
+ # ─── API: Optimize ───────────────────────────────────────
1142
+ @app.route('/api/optimize')
1143
+ def api_optimize():
1144
+ """Get optimization suggestions."""
1145
+ suggestions = session_mgr.get_optimization_suggestions()
1146
+ return jsonify({"suggestions": suggestions})
1147
+
1148
+
1149
+ # ─── API: Memory ─────────────────────────────────────────
1150
+ @app.route('/api/memory/facts')
1151
+ def api_memory_facts():
1152
+ """List all stored facts."""
1153
+ return jsonify(memory_store.facts)
1154
+
1155
+
1156
+ def _sync_memory_md():
1157
+ """Rewrite .c3/MEMORY.md as a markdown index of active facts, grouped by category."""
1158
+ try:
1159
+ facts = [f for f in memory_store.facts if f.get("lifecycle") != "archived"]
1160
+ facts.sort(key=lambda f: (f.get("relevance_count", 0), f.get("last_accessed_at") or ""), reverse=True)
1161
+ by_cat: dict = {}
1162
+ for f in facts:
1163
+ by_cat.setdefault(f.get("category", "general"), []).append(f)
1164
+ now = datetime.now(timezone.utc).isoformat()
1165
+ lines = [
1166
+ "# C3 Memory Index",
1167
+ "",
1168
+ f"_Auto-generated {now} — {len(facts)} active facts. Do not edit by hand._",
1169
+ "",
1170
+ ]
1171
+ for cat in sorted(by_cat):
1172
+ lines.append(f"## {cat}")
1173
+ lines.append("")
1174
+ for e in by_cat[cat]:
1175
+ fid = (e.get("id") or "")[:8]
1176
+ recalls = e.get("relevance_count", 0)
1177
+ lines.append(f"- `{fid}` (recalls={recalls}) — {e['fact']}")
1178
+ lines.append("")
1179
+ out = PROJECT_PATH / ".c3" / "MEMORY.md"
1180
+ out.parent.mkdir(parents=True, exist_ok=True)
1181
+ out.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
1182
+ except Exception:
1183
+ pass
1184
+
1185
+
1186
+ @app.route('/api/memory/remember', methods=['POST'])
1187
+ def api_memory_remember():
1188
+ """Store a new fact."""
1189
+ data = request.get_json() or {}
1190
+ fact = data.get("fact", "").strip()
1191
+ category = data.get("category", "general")
1192
+ if not fact:
1193
+ return jsonify({"error": "No fact specified"}), 400
1194
+ session_id = (session_mgr.current_session or {}).get("id", "")
1195
+ result = memory_store.remember(fact, category, source_session=session_id)
1196
+ _sync_memory_md()
1197
+ return jsonify(result)
1198
+
1199
+
1200
+ @app.route('/api/memory/recall', methods=['POST'])
1201
+ def api_memory_recall():
1202
+ """Search facts by query."""
1203
+ data = request.get_json() or {}
1204
+ query = data.get("query", "").strip()
1205
+ top_k = max(1, min(int(data.get("top_k", 3)), 10))
1206
+ if not query:
1207
+ return jsonify({"error": "No query specified"}), 400
1208
+ results = memory_store.recall(query, top_k)
1209
+ return jsonify(results)
1210
+
1211
+
1212
+ @app.route('/api/memory/query', methods=['POST'])
1213
+ def api_memory_query():
1214
+ """Search facts + sessions."""
1215
+ data = request.get_json() or {}
1216
+ query = data.get("query", "").strip()
1217
+ top_k = data.get("top_k", 5)
1218
+ if not query:
1219
+ return jsonify({"error": "No query specified"}), 400
1220
+ results = memory_store.query_all(query, top_k)
1221
+ return jsonify(results)
1222
+
1223
+
1224
+ @app.route('/api/memory/facts/<fact_id>', methods=['DELETE'])
1225
+ def api_memory_delete(fact_id):
1226
+ """Delete a fact by ID."""
1227
+ result = memory_store.delete_fact(fact_id)
1228
+ if "error" in result:
1229
+ return jsonify(result), 404
1230
+ _sync_memory_md()
1231
+ return jsonify(result)
1232
+
1233
+
1234
+ @app.route('/api/memory/export')
1235
+ def api_memory_export():
1236
+ """Export all active facts as markdown, grouped by category."""
1237
+ category = request.args.get("category", "")
1238
+ facts = [f for f in memory_store.facts if f.get("lifecycle") != "archived"]
1239
+ if category:
1240
+ facts = [f for f in facts if f.get("category") == category]
1241
+ facts.sort(key=lambda f: (f.get("relevance_count", 0), f.get("last_accessed_at") or ""), reverse=True)
1242
+ by_cat = {}
1243
+ for f in facts:
1244
+ by_cat.setdefault(f.get("category", "general"), []).append(f)
1245
+ lines = ["# C3 Memory Export", ""]
1246
+ for cat, entries in sorted(by_cat.items()):
1247
+ lines.append(f"## {cat}")
1248
+ lines.append("")
1249
+ for e in entries:
1250
+ lines.append(f"- {e['fact']}")
1251
+ lines.append("")
1252
+ return jsonify({"markdown": "\n".join(lines).rstrip() + "\n", "count": len(facts)})
1253
+
1254
+
1255
+ @app.route('/api/memory/graph')
1256
+ def api_memory_graph():
1257
+ """Return memory graph: nodes (facts + optional file/symbol nodes) + edges + clusters."""
1258
+ graph = getattr(runtime, "memory_graph", None)
1259
+ if graph is None:
1260
+ return jsonify({"nodes": [], "edges": [], "clusters": [], "stats": {}})
1261
+
1262
+ include_non_fact = request.args.get("include_non_fact", "0") == "1"
1263
+ min_weight = float(request.args.get("min_weight", 0) or 0)
1264
+
1265
+ facts_by_id = {f["id"]: f for f in memory_store.facts if f.get("lifecycle") != "archived"}
1266
+ edges = [
1267
+ {"src": e["src"], "dst": e["dst"], "type": e["type"], "weight": e.get("weight", 1.0)}
1268
+ for e in graph._edges
1269
+ if e.get("weight", 1.0) >= min_weight
1270
+ ]
1271
+
1272
+ node_ids: set[str] = set()
1273
+ for e in edges:
1274
+ node_ids.add(e["src"])
1275
+ node_ids.add(e["dst"])
1276
+
1277
+ nodes = []
1278
+ for nid in node_ids:
1279
+ if nid in facts_by_id:
1280
+ f = facts_by_id[nid]
1281
+ nodes.append({
1282
+ "id": nid,
1283
+ "kind": "fact",
1284
+ "label": (f.get("fact", "")[:80]),
1285
+ "category": f.get("category", "general"),
1286
+ "relevance": f.get("relevance_count", 0),
1287
+ "confidence": f.get("confidence", 1.0),
1288
+ "last_accessed": f.get("last_accessed_at", ""),
1289
+ })
1290
+ elif include_non_fact:
1291
+ kind = "file" if nid.startswith("file:") else ("symbol" if nid.startswith("symbol:") else "other")
1292
+ nodes.append({"id": nid, "kind": kind, "label": nid.split(":", 1)[-1]})
1293
+
1294
+ if not include_non_fact:
1295
+ keep = {n["id"] for n in nodes}
1296
+ edges = [e for e in edges if e["src"] in keep and e["dst"] in keep]
1297
+
1298
+ clusters = graph.detect_clusters()
1299
+ return jsonify({
1300
+ "nodes": nodes,
1301
+ "edges": edges,
1302
+ "clusters": clusters,
1303
+ "stats": graph.stats(),
1304
+ })
1305
+
1306
+
1307
+ @app.route('/api/memory/fact/<fact_id>')
1308
+ def api_memory_fact_detail(fact_id):
1309
+ """Return a single fact plus its graph neighbors."""
1310
+ fact = next((f for f in memory_store.facts if f.get("id") == fact_id), None)
1311
+ if not fact:
1312
+ return jsonify({"error": "not found"}), 404
1313
+ graph = getattr(runtime, "memory_graph", None)
1314
+ neighbors = []
1315
+ if graph is not None:
1316
+ for e in graph.get_edges(fact_id):
1317
+ other = e["dst"] if e["src"] == fact_id else e["src"]
1318
+ other_fact = next((f for f in memory_store.facts if f.get("id") == other), None)
1319
+ neighbors.append({
1320
+ "id": other,
1321
+ "type": e["type"],
1322
+ "weight": e.get("weight", 1.0),
1323
+ "label": (other_fact.get("fact", "")[:80] if other_fact else other),
1324
+ "kind": "fact" if other_fact else ("file" if other.startswith("file:") else "other"),
1325
+ })
1326
+ return jsonify({"fact": fact, "neighbors": neighbors})
1327
+
1328
+
1329
+ @app.route('/api/memory/facts/<fact_id>', methods=['PATCH'])
1330
+ def api_memory_update(fact_id):
1331
+ """Update a fact's text/category."""
1332
+ data = request.get_json() or {}
1333
+ result = memory_store.update_fact(
1334
+ fact_id,
1335
+ fact=data.get("fact", ""),
1336
+ category=data.get("category", ""),
1337
+ )
1338
+ if "error" in result:
1339
+ return jsonify(result), 404
1340
+ _sync_memory_md()
1341
+ return jsonify(result)
1342
+
1343
+
1344
+ def _get_consolidator():
1345
+ from services.memory_consolidator import MemoryConsolidator
1346
+ return MemoryConsolidator(
1347
+ memory_store=memory_store,
1348
+ graph=getattr(runtime, "memory_graph", None),
1349
+ project_path=str(PROJECT_PATH),
1350
+ )
1351
+
1352
+
1353
+ @app.route('/api/memory/trends')
1354
+ def api_memory_trends():
1355
+ """Recall frequency / category trends."""
1356
+ try:
1357
+ return jsonify(_get_consolidator().detect_trends())
1358
+ except Exception as e:
1359
+ return jsonify({"error": str(e)}), 500
1360
+
1361
+
1362
+ @app.route('/api/memory/lifespan')
1363
+ def api_memory_lifespan():
1364
+ """Fact age / staleness distribution."""
1365
+ try:
1366
+ return jsonify(_get_consolidator().fact_lifespan_analysis())
1367
+ except Exception as e:
1368
+ return jsonify({"error": str(e)}), 500
1369
+
1370
+
1371
+ @app.route('/api/memory/consolidate', methods=['POST'])
1372
+ def api_memory_consolidate():
1373
+ """Trigger consolidation pipeline."""
1374
+ try:
1375
+ session = (session_mgr.current_session if session_mgr else None)
1376
+ result = _get_consolidator().run(session)
1377
+ _sync_memory_md()
1378
+ return jsonify(result)
1379
+ except Exception as e:
1380
+ return jsonify({"error": str(e)}), 500
1381
+
1382
+
1383
+ @app.route('/api/memory/ground/<fact_id>', methods=['POST'])
1384
+ def api_memory_ground(fact_id):
1385
+ """Verify a fact against current code via MemoryGrounder."""
1386
+ from services.memory_grounder import MemoryGrounder
1387
+ fact = next((f for f in memory_store.facts if f.get("id") == fact_id), None)
1388
+ if not fact:
1389
+ return jsonify({"error": "not found"}), 404
1390
+ try:
1391
+ grounder = MemoryGrounder(str(PROJECT_PATH), memory_store)
1392
+ result = grounder.ground_fact(fact)
1393
+ return jsonify(result.to_dict() if hasattr(result, "to_dict") else result)
1394
+ except Exception as e:
1395
+ return jsonify({"error": str(e)}), 500
1396
+
1397
+
1398
+ # ─── API: Watcher (read-only from sessions) ──────────────
1399
+ @app.route('/api/watcher/changes')
1400
+ def api_watcher_changes():
1401
+ """Return recent file changes from session tool_calls."""
1402
+ sessions = session_mgr.list_sessions(5)
1403
+ changes = []
1404
+ session_dir = PROJECT_PATH / ".c3" / "sessions"
1405
+ for s in sessions[:5]:
1406
+ sf = session_dir / f"session_{s['id']}.json"
1407
+ if sf.exists():
1408
+ try:
1409
+ with open(sf, encoding='utf-8') as f:
1410
+ full = json.load(f)
1411
+ for tc in full.get("tool_calls", []):
1412
+ tool = tc.get("tool", "")
1413
+ if any(kw in tool.lower() for kw in ["write", "edit", "read", "file", "create"]):
1414
+ changes.append({
1415
+ "tool": tool,
1416
+ "args_summary": str(tc.get("args", {}))[:120],
1417
+ "timestamp": tc.get("timestamp", ""),
1418
+ "session_id": s["id"],
1419
+ })
1420
+ except Exception:
1421
+ continue
1422
+ return jsonify(changes[:50])
1423
+
1424
+
1425
+ # ─── API: Project Meta ────────────────────────────────────
1426
+ def _load_project_meta():
1427
+ """Load editable project metadata from .c3/config.json."""
1428
+ config_path = PROJECT_PATH / ".c3" / "config.json"
1429
+ if config_path.exists():
1430
+ try:
1431
+ with open(config_path, encoding="utf-8") as f:
1432
+ return json.load(f).get("meta", {})
1433
+ except Exception:
1434
+ pass
1435
+ return {}
1436
+
1437
+
1438
+ def _save_project_meta(meta: dict):
1439
+ """Save editable project metadata to .c3/config.json."""
1440
+ config_path = PROJECT_PATH / ".c3" / "config.json"
1441
+ config = {}
1442
+ if config_path.exists():
1443
+ try:
1444
+ with open(config_path, encoding="utf-8") as f:
1445
+ config = json.load(f)
1446
+ except Exception:
1447
+ pass
1448
+ config["meta"] = meta
1449
+ config_path.parent.mkdir(parents=True, exist_ok=True)
1450
+ with open(config_path, "w", encoding="utf-8") as f:
1451
+ json.dump(config, f, indent=2)
1452
+
1453
+
1454
+ @app.route('/api/project/meta')
1455
+ def api_project_meta_get():
1456
+ """Get editable project metadata."""
1457
+ return jsonify(_load_project_meta())
1458
+
1459
+
1460
+ @app.route('/api/project/meta', methods=['PUT'])
1461
+ def api_project_meta_put():
1462
+ """Update editable project metadata."""
1463
+ data = request.get_json() or {}
1464
+ meta = _load_project_meta()
1465
+ meta.update(data)
1466
+ _save_project_meta(meta)
1467
+ return jsonify(meta)
1468
+
1469
+
1470
+ # ─── API: Project Data Management ────────────────────────
1471
+ @app.route('/api/data/summary')
1472
+ def api_data_summary():
1473
+ """Return size and item counts for each .c3/ data category."""
1474
+ c3 = PROJECT_PATH / ".c3"
1475
+
1476
+ def _dir_stats(path, glob="*", skip_prefix="_"):
1477
+ if not path.exists():
1478
+ return {"count": 0, "size_kb": 0.0}
1479
+ files = [f for f in path.glob(glob) if f.is_file() and not f.name.startswith(skip_prefix)]
1480
+ size = sum(f.stat().st_size for f in files)
1481
+ return {"count": len(files), "size_kb": round(size / 1024, 1)}
1482
+
1483
+ # Index
1484
+ idx = _dir_stats(c3 / "index")
1485
+ idx_files = 0
1486
+ try:
1487
+ if indexer:
1488
+ idx_files = indexer._index.get("metadata", {}).get("files_indexed", 0) if indexer._index else 0
1489
+ except Exception:
1490
+ pass
1491
+ idx["files_indexed"] = idx_files
1492
+
1493
+ # Sessions
1494
+ sess_files = list((c3 / "sessions").glob("session_*.json")) if (c3 / "sessions").exists() else []
1495
+ sess_size = sum(f.stat().st_size for f in sess_files)
1496
+
1497
+ # Cache
1498
+ cache = _dir_stats(c3 / "cache")
1499
+
1500
+ # Snapshots
1501
+ snap_files = list((c3 / "snapshots").glob("snap_*.json")) if (c3 / "snapshots").exists() else []
1502
+ snap_size = sum(f.stat().st_size for f in snap_files)
1503
+
1504
+ # File memory maps
1505
+ fm = _dir_stats(c3 / "file_memory", "*.json")
1506
+
1507
+ # Notifications
1508
+ notif_path = c3 / "notifications.jsonl"
1509
+ notif_count = 0
1510
+ notif_size = 0
1511
+ if notif_path.exists():
1512
+ try:
1513
+ lines = notif_path.read_text(encoding="utf-8").strip().splitlines()
1514
+ notif_count = len([l for l in lines if l.strip()])
1515
+ notif_size = notif_path.stat().st_size
1516
+ except Exception:
1517
+ pass
1518
+
1519
+ # SLTM
1520
+ sltm_records = 0
1521
+ sltm_size = 0
1522
+ if vector_store:
1523
+ try:
1524
+ vstats = vector_store.get_stats()
1525
+ sltm_records = sum(c.get("count", 0) for c in vstats.get("collections", {}).values())
1526
+ except Exception:
1527
+ pass
1528
+ sltm_dir = c3 / "sltm"
1529
+ if sltm_dir.exists():
1530
+ for f in sltm_dir.rglob("*"):
1531
+ if f.is_file():
1532
+ try:
1533
+ sltm_size += f.stat().st_size
1534
+ except Exception:
1535
+ pass
1536
+
1537
+ # Total .c3/ size
1538
+ total_bytes = 0
1539
+ if c3.exists():
1540
+ for f in c3.rglob("*"):
1541
+ if f.is_file():
1542
+ try:
1543
+ total_bytes += f.stat().st_size
1544
+ except Exception:
1545
+ pass
1546
+
1547
+ return jsonify({
1548
+ "index": {"count": idx_files, "size_kb": idx["size_kb"]},
1549
+ "sessions": {"count": len(sess_files), "size_kb": round(sess_size / 1024, 1)},
1550
+ "cache": {"count": cache["count"], "size_kb": cache["size_kb"]},
1551
+ "snapshots": {"count": len(snap_files), "size_kb": round(snap_size / 1024, 1)},
1552
+ "file_memory": {"count": fm["count"], "size_kb": fm["size_kb"]},
1553
+ "notifications": {"count": notif_count, "size_kb": round(notif_size / 1024, 1)},
1554
+ "sltm": {"count": sltm_records, "size_kb": round(sltm_size / 1024, 1)},
1555
+ "total_kb": round(total_bytes / 1024, 1),
1556
+ })
1557
+
1558
+
1559
+ @app.route('/api/data/cache', methods=['DELETE'])
1560
+ def api_data_clear_cache():
1561
+ """Clear .c3/cache/ (compression cache)."""
1562
+ cache_dir = PROJECT_PATH / ".c3" / "cache"
1563
+ count = 0
1564
+ if cache_dir.exists():
1565
+ for f in cache_dir.iterdir():
1566
+ if f.is_file():
1567
+ f.unlink()
1568
+ count += 1
1569
+ return jsonify({"cleared": count})
1570
+
1571
+
1572
+ @app.route('/api/data/snapshots', methods=['DELETE'])
1573
+ def api_data_clear_snapshots():
1574
+ """Clear .c3/snapshots/ (context snapshots)."""
1575
+ snaps_dir = PROJECT_PATH / ".c3" / "snapshots"
1576
+ count = 0
1577
+ if snaps_dir.exists():
1578
+ for f in snaps_dir.glob("snap_*.json"):
1579
+ f.unlink()
1580
+ count += 1
1581
+ return jsonify({"cleared": count})
1582
+
1583
+
1584
+ @app.route('/api/data/file-memory', methods=['DELETE'])
1585
+ def api_data_clear_file_memory():
1586
+ """Clear .c3/file_memory/ structural maps (rebuilt on next use)."""
1587
+ fm_dir = PROJECT_PATH / ".c3" / "file_memory"
1588
+ count = 0
1589
+ if fm_dir.exists():
1590
+ for f in fm_dir.iterdir():
1591
+ if f.is_file() and not f.name.startswith("_"):
1592
+ f.unlink()
1593
+ count += 1
1594
+ return jsonify({"cleared": count})
1595
+
1596
+
1597
+ @app.route('/api/data/notifications', methods=['DELETE'])
1598
+ def api_data_clear_notifications():
1599
+ """Truncate the notifications queue."""
1600
+ notif_path = PROJECT_PATH / ".c3" / "notifications.jsonl"
1601
+ if notif_path.exists():
1602
+ notif_path.write_text("", encoding="utf-8")
1603
+ return jsonify({"cleared": True})
1604
+
1605
+
1606
+ @app.route('/api/data/sessions', methods=['DELETE'])
1607
+ def api_data_clear_sessions():
1608
+ """Delete old sessions, keeping the most recent N (default 5)."""
1609
+ keep = request.args.get('keep', 5, type=int)
1610
+ sessions_dir = PROJECT_PATH / ".c3" / "sessions"
1611
+ cleared = 0
1612
+ kept = 0
1613
+ if sessions_dir.exists():
1614
+ all_sessions = sorted(
1615
+ sessions_dir.glob("session_*.json"),
1616
+ key=lambda f: f.stat().st_mtime, reverse=True
1617
+ )
1618
+ kept = min(keep, len(all_sessions))
1619
+ for f in all_sessions[keep:]:
1620
+ f.unlink()
1621
+ cleared += 1
1622
+ return jsonify({"cleared": cleared, "kept": kept})
1623
+
1624
+
1625
+ # ─── API: Activity Log ────────────────────────────────────
1626
+ @app.route('/api/activity')
1627
+ def api_activity():
1628
+ """Get recent activity log events."""
1629
+ limit = request.args.get('limit', 100, type=int)
1630
+ event_type = request.args.get('type', None)
1631
+ if event_type == '':
1632
+ event_type = None
1633
+ since = request.args.get('since', None)
1634
+ until = request.args.get('until', None)
1635
+ events = activity_log.get_recent(limit, event_type, since=since, until=until)
1636
+ return jsonify(events)
1637
+
1638
+
1639
+ @app.route('/api/activity/stats')
1640
+ def api_activity_stats():
1641
+ """Get activity log statistics."""
1642
+ return jsonify(activity_log.get_stats())
1643
+
1644
+
1645
+ # ─── API: Edit Ledger ────────────────────────────────────
1646
+ @app.route('/api/edits')
1647
+ def api_edits_get():
1648
+ """Get edit history, optionally filtered by file."""
1649
+ file = request.args.get('file', None)
1650
+ limit = request.args.get('limit', 50, type=int)
1651
+ since = request.args.get('since', None)
1652
+ if runtime and runtime.edit_ledger:
1653
+ return jsonify(runtime.edit_ledger.get_history(file=file, limit=limit, since=since))
1654
+ return jsonify([])
1655
+
1656
+ @app.route('/api/edits', methods=['POST'])
1657
+ def api_edits_post():
1658
+ """Log a new edit."""
1659
+ if not runtime or not runtime.edit_ledger:
1660
+ return jsonify({"error": "edit ledger not available"}), 503
1661
+ data = request.get_json(force=True)
1662
+ entry = runtime.edit_ledger.log_edit(
1663
+ file=data.get("file", ""),
1664
+ change_type=data.get("change_type", "modified"),
1665
+ summary=data.get("summary", ""),
1666
+ lines_changed=data.get("lines_changed"),
1667
+ tags=data.get("tags"),
1668
+ session_id=data.get("session_id"),
1669
+ )
1670
+ return jsonify(entry)
1671
+
1672
+ @app.route('/api/edits/stats')
1673
+ def api_edits_stats():
1674
+ """Edit statistics."""
1675
+ if runtime and runtime.edit_ledger:
1676
+ return jsonify(runtime.edit_ledger.get_stats())
1677
+ return jsonify({"total": 0, "by_type": {}, "files": 0, "most_edited": []})
1678
+
1679
+ @app.route('/api/edits/versions/<path:filepath>')
1680
+ def api_edits_versions(filepath):
1681
+ """File version history."""
1682
+ if runtime and runtime.edit_ledger:
1683
+ return jsonify(runtime.edit_ledger.get_file_versions(filepath))
1684
+ return jsonify([])
1685
+
1686
+
1687
+ # ─── API: Notifications ──────────────────────────────────
1688
+ @app.route('/api/notifications')
1689
+ def api_notifications():
1690
+ """Get unacknowledged notifications.
1691
+
1692
+ Query params:
1693
+ limit: max entries (default 20)
1694
+ severities: comma-separated severity filter (default 'warning,critical').
1695
+ Pass 'all' or 'info,warning,critical' to include info events
1696
+ (e.g. for an activity log view).
1697
+ """
1698
+ limit = request.args.get('limit', 20, type=int)
1699
+ severities_raw = request.args.get('severities', 'warning,critical')
1700
+ if severities_raw in ('all', '*'):
1701
+ severities = ()
1702
+ else:
1703
+ severities = tuple(s.strip() for s in severities_raw.split(',') if s.strip())
1704
+ return jsonify(notification_store.get_unacknowledged(limit, severities=severities))
1705
+
1706
+
1707
+ @app.route('/api/notifications/history')
1708
+ def api_notifications_history():
1709
+ """Get historical notifications (including acknowledged) for the activity console."""
1710
+ limit = request.args.get('limit', 50, type=int)
1711
+ return jsonify(notification_store.get_history(limit))
1712
+
1713
+
1714
+ @app.route('/api/notifications/ack', methods=['POST'])
1715
+ def api_notifications_ack():
1716
+ """Acknowledge a notification by ID."""
1717
+ data = request.get_json(silent=True) or {}
1718
+ nid = data.get('id', '')
1719
+ if not nid:
1720
+ return jsonify({"error": "id required"}), 400
1721
+ found = notification_store.acknowledge(nid)
1722
+ return jsonify({"acknowledged": found, "id": nid})
1723
+
1724
+
1725
+ @app.route('/api/notifications/ack-all', methods=['POST'])
1726
+ def api_notifications_ack_all():
1727
+ """Acknowledge all notifications."""
1728
+ count = notification_store.acknowledge_all()
1729
+ return jsonify({"acknowledged": count})
1730
+
1731
+
1732
+ def _shutdown_process_after_delay(delay_seconds: float = 0.2):
1733
+ """Terminate this server process shortly after responding to the caller."""
1734
+ time.sleep(max(0.0, delay_seconds))
1735
+ try:
1736
+ os.kill(os.getpid(), signal.SIGINT)
1737
+ except Exception:
1738
+ os._exit(0)
1739
+
1740
+
1741
+ def _windows_process_name(pid: int) -> str:
1742
+ """Return lowercase executable name for a Windows PID, or empty string."""
1743
+ if not sys.platform.startswith("win") or pid <= 0:
1744
+ return ""
1745
+ try:
1746
+ out = subprocess.check_output(
1747
+ ["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
1748
+ stderr=subprocess.DEVNULL,
1749
+ text=True,
1750
+ encoding="utf-8",
1751
+ errors="ignore",
1752
+ ).strip()
1753
+ except Exception:
1754
+ return ""
1755
+ if not out or out.lower().startswith("info:"):
1756
+ return ""
1757
+ try:
1758
+ row = next(csv.reader([out]))
1759
+ except Exception:
1760
+ return ""
1761
+ if not row:
1762
+ return ""
1763
+ return str(row[0]).strip().lower()
1764
+
1765
+
1766
+ def _force_close_parent_terminal_if_safe() -> bool:
1767
+ """Best-effort Windows terminal close by killing the immediate parent shell tree."""
1768
+ if not sys.platform.startswith("win"):
1769
+ return False
1770
+ parent_pid = os.getppid()
1771
+ if parent_pid <= 0:
1772
+ return False
1773
+ parent_name = _windows_process_name(parent_pid)
1774
+ # Guardrail: only terminate common shell/terminal hosts, never unknown parents.
1775
+ allowed = {
1776
+ "powershell.exe",
1777
+ "pwsh.exe",
1778
+ "cmd.exe",
1779
+ "windowsterminal.exe",
1780
+ "conhost.exe",
1781
+ "mintty.exe",
1782
+ "wezterm-gui.exe",
1783
+ "alacritty.exe",
1784
+ }
1785
+ if parent_name not in allowed:
1786
+ return False
1787
+ try:
1788
+ subprocess.run(
1789
+ ["taskkill", "/PID", str(parent_pid), "/T", "/F"],
1790
+ stdout=subprocess.DEVNULL,
1791
+ stderr=subprocess.DEVNULL,
1792
+ check=False,
1793
+ text=True,
1794
+ )
1795
+ return True
1796
+ except Exception:
1797
+ return False
1798
+
1799
+
1800
+ def _shutdown_after_delay_with_optional_terminal_close(force_terminal_close: bool, delay_seconds: float = 0.2):
1801
+ """Shutdown server; optionally attempt safe parent terminal close on Windows first."""
1802
+ time.sleep(max(0.0, delay_seconds))
1803
+ if force_terminal_close and _force_close_parent_terminal_if_safe():
1804
+ return
1805
+ try:
1806
+ os.kill(os.getpid(), signal.SIGINT)
1807
+ except Exception:
1808
+ os._exit(0)
1809
+
1810
+
1811
+ @app.route('/api/shutdown', methods=['POST'])
1812
+ def api_shutdown():
1813
+ """Shut down C3 UI server (equivalent to pressing Ctrl+C in terminal)."""
1814
+ data = request.get_json(silent=True) or {}
1815
+ force_terminal_close = bool(data.get("force_close_terminal", False))
1816
+ try:
1817
+ if session_mgr and getattr(session_mgr, "current_session", None):
1818
+ session_mgr.save_session("Shutdown requested from UI")
1819
+ except Exception:
1820
+ pass
1821
+
1822
+ threading.Thread(
1823
+ target=_shutdown_after_delay_with_optional_terminal_close,
1824
+ args=(force_terminal_close,),
1825
+ daemon=True,
1826
+ name="c3-ui-shutdown",
1827
+ ).start()
1828
+ return jsonify({
1829
+ "success": True,
1830
+ "message": "C3 is shutting down",
1831
+ "force_terminal_close_requested": force_terminal_close,
1832
+ "force_terminal_close_supported": sys.platform.startswith("win"),
1833
+ })
1834
+
1835
+
1836
+ # ─── API: Hybrid Metrics & Config ─────────────────────────
1837
+ @app.route('/api/hybrid/metrics')
1838
+ def api_hybrid_metrics():
1839
+ """Get all tier metrics."""
1840
+ if not metrics_collector:
1841
+ return jsonify({"error": "No hybrid services initialized"})
1842
+ return jsonify(metrics_collector.collect())
1843
+
1844
+
1845
+ @app.route('/api/hybrid/config', methods=['GET'])
1846
+ def api_hybrid_config_get():
1847
+ """Get current hybrid feature flags and config."""
1848
+ return jsonify(hybrid_config or {})
1849
+
1850
+
1851
+ @app.route('/api/hybrid/config', methods=['PUT'])
1852
+ def api_hybrid_config_put():
1853
+ """Update hybrid feature flags. Persists to .c3/config.json."""
1854
+ global hybrid_config
1855
+ data = request.get_json() or {}
1856
+
1857
+ # Only allow updating known hybrid keys
1858
+ from core.config import DEFAULTS
1859
+ allowed_keys = set(DEFAULTS.keys())
1860
+ updates = {k: v for k, v in data.items() if k in allowed_keys}
1861
+ if "show_savings_footer" in updates and "SHOW_SAVINGS_SUMMARY" not in updates:
1862
+ updates["SHOW_SAVINGS_SUMMARY"] = bool(updates["show_savings_footer"])
1863
+ elif "SHOW_SAVINGS_SUMMARY" in updates and "show_savings_footer" not in updates:
1864
+ updates["show_savings_footer"] = bool(updates["SHOW_SAVINGS_SUMMARY"])
1865
+
1866
+ if not updates:
1867
+ return jsonify({"error": "No valid keys to update"}), 400
1868
+
1869
+ # Update in-memory config
1870
+ if hybrid_config:
1871
+ hybrid_config.update(updates)
1872
+
1873
+ # Persist to disk
1874
+ config_path = PROJECT_PATH / ".c3" / "config.json"
1875
+ config = {}
1876
+ if config_path.exists():
1877
+ try:
1878
+ with open(config_path, encoding="utf-8") as f:
1879
+ config = json.load(f)
1880
+ except Exception:
1881
+ pass
1882
+ config.setdefault("hybrid", {}).update(updates)
1883
+ config_path.parent.mkdir(parents=True, exist_ok=True)
1884
+ with open(config_path, "w", encoding="utf-8") as f:
1885
+ json.dump(config, f, indent=2)
1886
+
1887
+ return jsonify(hybrid_config or {})
1888
+
1889
+
1890
+ # ─── API: Budget Config ───────────────────────────────────
1891
+ @app.route('/api/budget/config', methods=['GET'])
1892
+ def api_budget_config_get():
1893
+ """Get current context budget threshold."""
1894
+ config_path = PROJECT_PATH / ".c3" / "config.json"
1895
+ config = {}
1896
+ if config_path.exists():
1897
+ try:
1898
+ with open(config_path, encoding="utf-8") as f:
1899
+ config = json.load(f)
1900
+ except Exception:
1901
+ pass
1902
+ budget = config.get("context_budget", {})
1903
+ defaults = SessionManager.DEFAULT_BUDGET_THRESHOLDS
1904
+ return jsonify({
1905
+ "threshold": budget.get("threshold", defaults["threshold"]),
1906
+ "show_context_nudges": (hybrid_config or {}).get("show_context_nudges", True),
1907
+ })
1908
+
1909
+
1910
+ @app.route('/api/budget/config', methods=['PUT'])
1911
+ def api_budget_config_put():
1912
+ """Update context budget threshold. Persists to .c3/config.json."""
1913
+ data = request.get_json() or {}
1914
+ config_path = PROJECT_PATH / ".c3" / "config.json"
1915
+ config = {}
1916
+ if config_path.exists():
1917
+ try:
1918
+ with open(config_path, encoding="utf-8") as f:
1919
+ config = json.load(f)
1920
+ except Exception:
1921
+ pass
1922
+
1923
+ if "threshold" in data:
1924
+ try:
1925
+ config.setdefault("context_budget", {})["threshold"] = max(1000, int(data["threshold"]))
1926
+ except (ValueError, TypeError):
1927
+ return jsonify({"error": "threshold must be an integer >= 1000"}), 400
1928
+
1929
+ if "show_context_nudges" in data:
1930
+ config.setdefault("hybrid", {})["show_context_nudges"] = bool(data["show_context_nudges"])
1931
+
1932
+ # Persist
1933
+ config_path.parent.mkdir(parents=True, exist_ok=True)
1934
+ with open(config_path, "w", encoding="utf-8") as f:
1935
+ json.dump(config, f, indent=2)
1936
+
1937
+ # Reload budget thresholds in session manager
1938
+ try:
1939
+ svc = app.config.get("svc")
1940
+ if svc and hasattr(svc, "session_mgr"):
1941
+ svc.session_mgr._budget_thresholds = svc.session_mgr._load_budget_thresholds()
1942
+ except Exception:
1943
+ pass
1944
+
1945
+ defaults = SessionManager.DEFAULT_BUDGET_THRESHOLDS
1946
+ merged_budget = config.get("context_budget", {})
1947
+ return jsonify({
1948
+ "threshold": merged_budget.get("threshold", defaults["threshold"]),
1949
+ "show_context_nudges": (hybrid_config or {}).get("show_context_nudges", True),
1950
+ })
1951
+
1952
+
1953
+ # ─── API: Permissions ─────────────────────────────────────
1954
+ @app.route('/api/permissions', methods=['GET'])
1955
+ def api_permissions_get():
1956
+ """Get current permission tier and available tiers (Claude Code only)."""
1957
+ from cli.c3 import PERMISSION_TIERS, _build_permission_tier, _detect_current_tier
1958
+ from core.ide import load_ide_config
1959
+ ide = load_ide_config(str(PROJECT_PATH))
1960
+ settings_path = PROJECT_PATH / ".claude" / "settings.local.json"
1961
+ current = _detect_current_tier(settings_path)
1962
+ # Also check .c3/config.json for stored tier choice
1963
+ config_path = PROJECT_PATH / ".c3" / "config.json"
1964
+ stored_tier = None
1965
+ if config_path.exists():
1966
+ try:
1967
+ with open(config_path, encoding="utf-8") as f:
1968
+ stored_tier = json.load(f).get("permission_tier")
1969
+ except Exception:
1970
+ pass
1971
+ # Count current rules
1972
+ allow_count = 0
1973
+ deny_count = 0
1974
+ if settings_path.exists():
1975
+ try:
1976
+ with open(settings_path, encoding="utf-8") as f:
1977
+ s = json.load(f)
1978
+ allow_count = len(s.get("permissions", {}).get("allow", []))
1979
+ deny_count = len(s.get("permissions", {}).get("deny", []))
1980
+ except Exception:
1981
+ pass
1982
+ return jsonify({
1983
+ "current_tier": current or stored_tier,
1984
+ "detected_tier": current,
1985
+ "stored_tier": stored_tier,
1986
+ "allow_count": allow_count,
1987
+ "deny_count": deny_count,
1988
+ "ide": ide,
1989
+ "supported": ide == "claude-code",
1990
+ "tiers": {name: {"description": desc, "allow_count": len(_build_permission_tier(name)["permissions"]["allow"]),
1991
+ "deny_count": len(_build_permission_tier(name)["permissions"]["deny"])}
1992
+ for name, desc in PERMISSION_TIERS.items()},
1993
+ })
1994
+
1995
+
1996
+ @app.route('/api/permissions', methods=['PUT'])
1997
+ def api_permissions_put():
1998
+ """Apply a permission tier. Body: {tier: "read-only"|"standard"|"permissive"}"""
1999
+ from cli.c3 import PERMISSION_TIERS, _build_permission_tier, _safe_read_json
2000
+ data = request.get_json() or {}
2001
+ tier = data.get("tier", "").strip()
2002
+ if tier not in PERMISSION_TIERS:
2003
+ return jsonify({"error": f"Unknown tier: {tier}. Available: {', '.join(PERMISSION_TIERS)}"}), 400
2004
+
2005
+ tier_perms = _build_permission_tier(tier)
2006
+ settings_path = PROJECT_PATH / ".claude" / "settings.local.json"
2007
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
2008
+ settings = _safe_read_json(settings_path, str(settings_path))
2009
+ settings["permissions"] = tier_perms["permissions"]
2010
+ with open(settings_path, "w", encoding="utf-8") as f:
2011
+ json.dump(settings, f, indent=2)
2012
+
2013
+ # Persist tier choice
2014
+ config_path = PROJECT_PATH / ".c3" / "config.json"
2015
+ config = {}
2016
+ if config_path.exists():
2017
+ try:
2018
+ with open(config_path, encoding="utf-8") as f:
2019
+ config = json.load(f)
2020
+ except Exception:
2021
+ pass
2022
+ config["permission_tier"] = tier
2023
+ config_path.parent.mkdir(parents=True, exist_ok=True)
2024
+ with open(config_path, "w", encoding="utf-8") as f:
2025
+ json.dump(config, f, indent=2)
2026
+
2027
+ return jsonify({
2028
+ "current_tier": tier,
2029
+ "allow_count": len(tier_perms["permissions"]["allow"]),
2030
+ "deny_count": len(tier_perms["permissions"]["deny"]),
2031
+ "message": f"Applied '{tier}' permissions. Restart Claude Code to activate.",
2032
+ })
2033
+
2034
+
2035
+ # ─── API: Agent Config ────────────────────────────────────
2036
+ @app.route('/api/agents/config', methods=['GET'])
2037
+ def api_agents_config_get():
2038
+ """Get current agent config (merged with defaults)."""
2039
+ from core.config import load_agent_config
2040
+ return jsonify(load_agent_config(str(PROJECT_PATH)))
2041
+
2042
+
2043
+ @app.route('/api/agents/config', methods=['PUT'])
2044
+ def api_agents_config_put():
2045
+ """Update agent config. Persists to .c3/config.json."""
2046
+ from core.config import AGENT_DEFAULTS
2047
+ global agents
2048
+ data = request.get_json() or {}
2049
+
2050
+ # Validate: only accept known agent names and known keys
2051
+ updates = {}
2052
+ for agent_name, overrides in data.items():
2053
+ if agent_name not in AGENT_DEFAULTS:
2054
+ continue
2055
+ if not isinstance(overrides, dict):
2056
+ continue
2057
+ allowed_keys = set(AGENT_DEFAULTS[agent_name].keys())
2058
+ valid = {k: v for k, v in overrides.items() if k in allowed_keys}
2059
+ if valid:
2060
+ updates[agent_name] = valid
2061
+
2062
+ if not updates:
2063
+ return jsonify({"error": "No valid agent config keys to update"}), 400
2064
+
2065
+ # Persist to disk
2066
+ config_path = PROJECT_PATH / ".c3" / "config.json"
2067
+ config = {}
2068
+ if config_path.exists():
2069
+ try:
2070
+ with open(config_path, encoding="utf-8") as f:
2071
+ config = json.load(f)
2072
+ except Exception:
2073
+ pass
2074
+ agents_cfg = config.setdefault("agents", {})
2075
+ for agent_name, overrides in updates.items():
2076
+ agents_cfg.setdefault(agent_name, {}).update(overrides)
2077
+ config_path.parent.mkdir(parents=True, exist_ok=True)
2078
+ with open(config_path, "w", encoding="utf-8") as f:
2079
+ json.dump(config, f, indent=2)
2080
+
2081
+ # Apply updates to any live in-process agents so the UI server reflects changes immediately.
2082
+ by_name = {agent.name: agent for agent in (agents or [])}
2083
+ for agent_name, overrides in updates.items():
2084
+ agent = by_name.get(agent_name)
2085
+ if not agent:
2086
+ continue
2087
+ was_enabled = bool(getattr(agent, "enabled", False))
2088
+ for key, value in overrides.items():
2089
+ if hasattr(agent, key):
2090
+ setattr(agent, key, value)
2091
+ now_enabled = bool(getattr(agent, "enabled", False))
2092
+ if was_enabled and not now_enabled:
2093
+ agent.stop()
2094
+ elif now_enabled and not getattr(agent, "_thread", None):
2095
+ agent.start()
2096
+ elif now_enabled and getattr(agent, "_thread", None) and not agent._thread.is_alive():
2097
+ agent.start()
2098
+
2099
+ # Return merged config
2100
+ from core.config import load_agent_config
2101
+ return jsonify(load_agent_config(str(PROJECT_PATH)))
2102
+
2103
+
2104
+ @app.route('/api/agents/status', methods=['GET'])
2105
+ def api_agents_status():
2106
+ """Return runtime status for all initialized background agents."""
2107
+ if not agents:
2108
+ return jsonify({"agents": [], "count": 0, "running": 0})
2109
+ statuses = [agent.get_status() for agent in agents]
2110
+ return jsonify({
2111
+ "agents": statuses,
2112
+ "count": len(statuses),
2113
+ "running": sum(1 for item in statuses if item.get("running")),
2114
+ })
2115
+
2116
+
2117
+ @app.route('/api/agents/run/<agent_name>', methods=['POST'])
2118
+ def api_agents_run(agent_name):
2119
+ """Run a single background agent check immediately."""
2120
+ target = None
2121
+ for agent in agents or []:
2122
+ if agent.name.lower() == agent_name.lower():
2123
+ target = agent
2124
+ break
2125
+
2126
+ if not target:
2127
+ return jsonify({"error": f"Unknown agent: {agent_name}"}), 404
2128
+
2129
+ result = target.run_once()
2130
+ status = result.get("status", target.get_status())
2131
+ if result.get("ok"):
2132
+ return jsonify({
2133
+ "success": True,
2134
+ "agent": target.name,
2135
+ "status": status,
2136
+ })
2137
+ return jsonify({
2138
+ "error": result.get("error", "Agent run failed"),
2139
+ "agent": target.name,
2140
+ "status": status,
2141
+ }), 500
2142
+
2143
+
2144
+ @app.route('/api/delegate/config', methods=['GET'])
2145
+ def api_delegate_config_get():
2146
+ """Get current delegate config (merged with defaults)."""
2147
+ return jsonify(load_delegate_config(str(PROJECT_PATH)))
2148
+
2149
+
2150
+ @app.route('/api/delegate/config', methods=['PUT'])
2151
+ def api_delegate_config_put():
2152
+ """Update delegate config. Persists to .c3/config.json."""
2153
+ from core.config import DELEGATE_DEFAULTS
2154
+ data = request.get_json() or {}
2155
+ allowed_keys = set(DELEGATE_DEFAULTS.keys())
2156
+ updates = {k: v for k, v in data.items() if k in allowed_keys}
2157
+
2158
+ if not updates:
2159
+ return jsonify({"error": "No valid delegate config keys to update"}), 400
2160
+
2161
+ config_path = PROJECT_PATH / ".c3" / "config.json"
2162
+ config = {}
2163
+ if config_path.exists():
2164
+ try:
2165
+ with open(config_path, encoding="utf-8") as f:
2166
+ config = json.load(f)
2167
+ except Exception:
2168
+ pass
2169
+ config.setdefault("delegate", {}).update(updates)
2170
+ config_path.parent.mkdir(parents=True, exist_ok=True)
2171
+ with open(config_path, "w", encoding="utf-8") as f:
2172
+ json.dump(config, f, indent=2)
2173
+
2174
+ return jsonify(load_delegate_config(str(PROJECT_PATH)))
2175
+
2176
+
2177
+ @app.route('/api/ollama/models')
2178
+ def api_ollama_models():
2179
+ """List locally available Ollama models."""
2180
+ from services.ollama_client import OllamaClient
2181
+ base_url = (hybrid_config or {}).get("ollama_base_url", "http://localhost:11434")
2182
+ client = OllamaClient(base_url)
2183
+ models = client.list_models()
2184
+ if models is None:
2185
+ return jsonify({"error": "Ollama not reachable", "models": []}), 503
2186
+ return jsonify({"models": models})
2187
+
2188
+
2189
+ @app.route('/api/delegate', methods=['POST'])
2190
+ def api_delegate():
2191
+ """Delegate a task to local LLM with optional streaming."""
2192
+ data = request.get_json() or {}
2193
+ task = data.get("task", "").strip()
2194
+ task_type = data.get("task_type", "ask")
2195
+ file_path = data.get("file_path", "")
2196
+ stream = bool(data.get("stream", False))
2197
+
2198
+ if not task:
2199
+ return jsonify({"error": "No task specified"}), 400
2200
+
2201
+ if not router:
2202
+ return jsonify({"error": "Router not initialized"}), 503
2203
+
2204
+ if stream:
2205
+ def generate_sse():
2206
+ result = router.route(task, force_class=task_type)
2207
+ # If router didn't return a generator, it means it's a non-streaming result or error
2208
+ response_gen = result.get("response")
2209
+
2210
+ # Metadata chunk
2211
+ meta = {
2212
+ "model": result.get("model"),
2213
+ "route_class": result.get("route_class"),
2214
+ "latency_ms": result.get("latency_ms"),
2215
+ "is_meta": True
2216
+ }
2217
+ yield f"data: {json.dumps(meta)}\n\n"
2218
+
2219
+ if isinstance(response_gen, str):
2220
+ yield f"data: {json.dumps({'text': response_gen})}\n\n"
2221
+ elif response_gen:
2222
+ for chunk in response_gen:
2223
+ yield f"data: {json.dumps({'text': chunk})}\n\n"
2224
+
2225
+ yield "data: [DONE]\n\n"
2226
+
2227
+ return Response(generate_sse(), mimetype='text/event-stream')
2228
+
2229
+ # Non-streaming path
2230
+ result = router.route(task, force_class=task_type)
2231
+ return jsonify(result)
2232
+
2233
+
2234
+ @app.route('/api/summarize', methods=['POST'])
2235
+ def api_summarize():
2236
+ """Summarize text with optional streaming."""
2237
+ data = request.get_json() or {}
2238
+ text = data.get("text", "").strip()
2239
+ style = data.get("style", "concise")
2240
+ stream = bool(data.get("stream", False))
2241
+
2242
+ if not text:
2243
+ return jsonify({"error": "No text specified"}), 400
2244
+
2245
+ if not router:
2246
+ return jsonify({"error": "Router not initialized"}), 503
2247
+
2248
+ if stream:
2249
+ def generate_sse():
2250
+ result = router.summarize(text, style=style)
2251
+ # Update: router.summarize needs to handle streaming too
2252
+ # For now, let's just wrap the result if it's not a generator
2253
+ summary = result.get("summary")
2254
+
2255
+ meta = {
2256
+ "model": result.get("model"),
2257
+ "style": result.get("style"),
2258
+ "is_meta": True
2259
+ }
2260
+ yield f"data: {json.dumps(meta)}\n\n"
2261
+
2262
+ if isinstance(summary, str):
2263
+ yield f"data: {json.dumps({'text': summary})}\n\n"
2264
+ elif summary:
2265
+ for chunk in summary:
2266
+ yield f"data: {json.dumps({'text': chunk})}\n\n"
2267
+
2268
+ yield "data: [DONE]\n\n"
2269
+
2270
+ return Response(generate_sse(), mimetype='text/event-stream')
2271
+
2272
+ result = router.summarize(text, style=style)
2273
+ return jsonify(result)
2274
+
2275
+
2276
+ # ─── API: SLTM ───────────────────────────────────────────
2277
+ @app.route('/api/sltm/stats')
2278
+ def api_sltm_stats():
2279
+ """Get SLTM backend status and collection sizes."""
2280
+ if not vector_store:
2281
+ return jsonify({"error": "SLTM not initialized", "vector_enabled": False})
2282
+ return jsonify(vector_store.get_stats())
2283
+
2284
+
2285
+ @app.route('/api/sltm/search', methods=['POST'])
2286
+ def api_sltm_search():
2287
+ """Search SLTM with hybrid TF-IDF + vector search."""
2288
+ if not vector_store:
2289
+ return jsonify({"error": "SLTM not initialized"}), 503
2290
+ data = request.get_json() or {}
2291
+ query = data.get("query", "").strip()
2292
+ category = data.get("category", "")
2293
+ top_k = data.get("top_k", 5)
2294
+ if not query:
2295
+ return jsonify({"error": "No query specified"}), 400
2296
+ results = vector_store.search(query, category, top_k)
2297
+ return jsonify(results)
2298
+
2299
+
2300
+ @app.route('/api/sltm/add', methods=['POST'])
2301
+ def api_sltm_add():
2302
+ """Add a record to SLTM."""
2303
+ if not vector_store:
2304
+ return jsonify({"error": "SLTM not initialized"}), 503
2305
+ data = request.get_json() or {}
2306
+ text = data.get("text", "").strip()
2307
+ category = data.get("category", "general")
2308
+ metadata = data.get("metadata", {})
2309
+ if not text:
2310
+ return jsonify({"error": "No text specified"}), 400
2311
+ result = vector_store.add(text, category, metadata)
2312
+ return jsonify(result)
2313
+
2314
+
2315
+ # ─── API: Proxy ──────────────────────────────────────────
2316
+ @app.route('/api/proxy/metrics')
2317
+ def api_proxy_metrics():
2318
+ """Return proxy metrics from .c3/proxy_metrics.json."""
2319
+ metrics_file = PROJECT_PATH / ".c3" / "proxy_metrics.json"
2320
+ if not metrics_file.exists():
2321
+ return jsonify({"error": "No proxy metrics found", "hint": "Proxy writes metrics on shutdown"})
2322
+ try:
2323
+ with open(metrics_file, encoding="utf-8") as f:
2324
+ return jsonify(json.load(f))
2325
+ except Exception as e:
2326
+ return jsonify({"error": str(e)}), 500
2327
+
2328
+
2329
+ @app.route('/api/proxy/state')
2330
+ def api_proxy_state():
2331
+ """Return live proxy state from .c3/proxy_state.json."""
2332
+ state_file = PROJECT_PATH / ".c3" / "proxy_state.json"
2333
+ if not state_file.exists():
2334
+ return jsonify({"error": "No proxy state found", "hint": "Proxy writes state after each tool call"})
2335
+ try:
2336
+ with open(state_file, encoding="utf-8") as f:
2337
+ return jsonify(json.load(f))
2338
+ except Exception as e:
2339
+ return jsonify({"error": str(e)}), 500
2340
+
2341
+
2342
+ @app.route('/api/proxy/config', methods=['GET'])
2343
+ def api_proxy_config_get():
2344
+ """Return current proxy configuration."""
2345
+ cfg = load_proxy_config(str(PROJECT_PATH))
2346
+ return jsonify(cfg)
2347
+
2348
+
2349
+ @app.route('/api/proxy/config', methods=['PUT'])
2350
+ def api_proxy_config_put():
2351
+ """Update proxy configuration in .c3/config.json."""
2352
+ updates = request.get_json() or {}
2353
+ config_file = PROJECT_PATH / ".c3" / "config.json"
2354
+ data = {}
2355
+ if config_file.exists():
2356
+ try:
2357
+ with open(config_file, encoding="utf-8") as f:
2358
+ data = json.load(f)
2359
+ except Exception:
2360
+ pass
2361
+ proxy = data.get("proxy", {})
2362
+ proxy.update(updates)
2363
+ data["proxy"] = proxy
2364
+ config_file.parent.mkdir(parents=True, exist_ok=True)
2365
+ with open(config_file, "w", encoding="utf-8") as f:
2366
+ json.dump(data, f, indent=2)
2367
+ return jsonify(load_proxy_config(str(PROJECT_PATH)))
2368
+
2369
+
2370
+ @app.route('/api/proxy/tools')
2371
+ def api_proxy_tools():
2372
+ """Return full tool inventory with categories and visibility status."""
2373
+ from services.tool_classifier import CATEGORIES
2374
+ cfg = load_proxy_config(str(PROJECT_PATH))
2375
+ always_visible = cfg.get("always_visible", ["core"])
2376
+ filter_enabled = cfg.get("filter_tools", False)
2377
+ effective_filtering = bool(filter_enabled) and "all" not in always_visible
2378
+
2379
+ # Build tool list with category info
2380
+ tools = []
2381
+ for cat_name, cat_info in CATEGORIES.items():
2382
+ for tool_name in cat_info["tools"]:
2383
+ visible = (not filter_enabled
2384
+ or "all" in always_visible
2385
+ or cat_name in always_visible)
2386
+ tools.append({
2387
+ "name": tool_name,
2388
+ "category": cat_name,
2389
+ "visible": visible,
2390
+ "priority": cat_info.get("priority", 99),
2391
+ })
2392
+
2393
+ return jsonify({
2394
+ "tools": tools,
2395
+ "categories": {
2396
+ name: {
2397
+ "tools": info["tools"],
2398
+ "priority": info.get("priority", 99),
2399
+ "pinned": (not filter_enabled
2400
+ or "all" in always_visible
2401
+ or name in always_visible),
2402
+ }
2403
+ for name, info in CATEGORIES.items()
2404
+ },
2405
+ "filter_enabled": filter_enabled,
2406
+ "effective_filtering": effective_filtering,
2407
+ "always_visible": always_visible,
2408
+ })
2409
+
2410
+
2411
+ # ─── API: MCP Status ─────────────────────────────────────
2412
+ def _parse_toml_mcp_servers(content: str) -> dict:
2413
+ """Parse [mcp_servers.<name>] sections from TOML content."""
2414
+ servers = {}
2415
+ current_server = None
2416
+
2417
+ for raw in content.splitlines():
2418
+ line = raw.split("#", 1)[0].strip()
2419
+ if not line:
2420
+ continue
2421
+
2422
+ if line.startswith("[") and line.endswith("]"):
2423
+ section = line[1:-1].strip()
2424
+ if section.startswith("mcp_servers."):
2425
+ current_server = section.split(".", 1)[1]
2426
+ servers.setdefault(current_server, {})
2427
+ else:
2428
+ current_server = None
2429
+ continue
2430
+
2431
+ if not current_server or "=" not in line:
2432
+ continue
2433
+
2434
+ key, value = line.split("=", 1)
2435
+ key = key.strip()
2436
+ value = value.strip()
2437
+
2438
+ if key == "args":
2439
+ servers[current_server]["args"] = re.findall(r"[\"']([^\"']*)[\"']", value)
2440
+ elif key in ("command", "type"):
2441
+ match = re.match(r"^[\"'](.*)[\"']$", value)
2442
+ servers[current_server][key] = match.group(1) if match else value
2443
+ elif key == "enabled":
2444
+ low = value.lower()
2445
+ if low.startswith("true"):
2446
+ servers[current_server]["enabled"] = True
2447
+ elif low.startswith("false"):
2448
+ servers[current_server]["enabled"] = False
2449
+ else:
2450
+ servers[current_server][key] = value
2451
+
2452
+ return servers
2453
+
2454
+
2455
+ def _find_server_script(servers: dict) -> bool:
2456
+ """Check whether any configured MCP server args reference an existing .py script."""
2457
+ for srv in servers.values():
2458
+ args = srv.get("args", []) if isinstance(srv, dict) else []
2459
+ for arg in args:
2460
+ if isinstance(arg, str) and arg.endswith(".py") and Path(arg).exists():
2461
+ return True
2462
+ return False
2463
+
2464
+
2465
+ def _toml_escape_str(value: str) -> str:
2466
+ return value.replace("\\", "/")
2467
+
2468
+
2469
+ def _upsert_toml_section(toml_path: Path, section: str, entries: dict) -> None:
2470
+ """Add or replace a dotted TOML section in-place."""
2471
+ content = toml_path.read_text(encoding="utf-8") if toml_path.exists() else ""
2472
+ header = f"[{section}]"
2473
+
2474
+ lines = content.splitlines()
2475
+ new_lines = []
2476
+ skip = False
2477
+ for line in lines:
2478
+ stripped = line.strip()
2479
+ if stripped == header:
2480
+ skip = True
2481
+ continue
2482
+ if skip and stripped.startswith("["):
2483
+ skip = False
2484
+ if not skip:
2485
+ new_lines.append(line)
2486
+
2487
+ content = "\n".join(new_lines).rstrip()
2488
+ section_lines = [f"\n\n{header}"]
2489
+ for k, v in entries.items():
2490
+ if isinstance(v, list):
2491
+ items = ", ".join(f'"{_toml_escape_str(str(x))}"' for x in v)
2492
+ section_lines.append(f'{k} = [{items}]')
2493
+ elif isinstance(v, bool):
2494
+ section_lines.append(f'{k} = {"true" if v else "false"}')
2495
+ else:
2496
+ section_lines.append(f'{k} = "{_toml_escape_str(str(v))}"')
2497
+ section_lines.append("")
2498
+
2499
+ toml_path.parent.mkdir(parents=True, exist_ok=True)
2500
+ toml_path.write_text(content + "\n".join(section_lines), encoding="utf-8")
2501
+
2502
+
2503
+ def _remove_toml_section(toml_path: Path, section: str) -> bool:
2504
+ """Remove a dotted TOML section. Returns True if removed."""
2505
+ if not toml_path.exists():
2506
+ return False
2507
+ content = toml_path.read_text(encoding="utf-8")
2508
+ header = f"[{section}]"
2509
+
2510
+ lines = content.splitlines()
2511
+ new_lines = []
2512
+ skip = False
2513
+ removed = False
2514
+ for line in lines:
2515
+ stripped = line.strip()
2516
+ if stripped == header:
2517
+ skip = True
2518
+ removed = True
2519
+ continue
2520
+ if skip and stripped.startswith("["):
2521
+ skip = False
2522
+ if not skip:
2523
+ new_lines.append(line)
2524
+
2525
+ if removed:
2526
+ toml_path.write_text("\n".join(new_lines).rstrip() + "\n", encoding="utf-8")
2527
+ return removed
2528
+
2529
+
2530
+ def _resolve_mcp_profile(ide_name: str | None):
2531
+ requested = (ide_name or "").strip().lower()
2532
+ if requested and requested != "auto":
2533
+ requested = normalize_ide_name(requested)
2534
+ if requested not in PROFILES:
2535
+ raise ValueError(f"Unknown IDE profile: {requested}")
2536
+ return requested, get_profile(requested)
2537
+
2538
+ configured_ide = load_ide_config(str(PROJECT_PATH))
2539
+ detected_ide = detect_ide(str(PROJECT_PATH))
2540
+ active_ide = configured_ide or detected_ide or "claude-code"
2541
+ return active_ide, get_profile(active_ide)
2542
+
2543
+
2544
+ def _mcp_config_path_for_profile(profile) -> Path:
2545
+ return (Path.home() / profile.config_path) if profile.config_path_global else (PROJECT_PATH / profile.config_path)
2546
+
2547
+
2548
+ def _read_mcp_servers_for_profile(profile, mcp_file: Path) -> tuple[dict, dict]:
2549
+ """Read MCP servers for a profile. Returns (servers, full_json_config)."""
2550
+ if not mcp_file.exists():
2551
+ return {}, {}
2552
+
2553
+ if profile.config_format == "toml":
2554
+ content = mcp_file.read_text(encoding="utf-8")
2555
+ return _parse_toml_mcp_servers(content), {}
2556
+
2557
+ with open(mcp_file, encoding="utf-8") as f:
2558
+ raw_config = json.load(f)
2559
+ servers = raw_config.get(profile.config_key, {})
2560
+ if not isinstance(servers, dict):
2561
+ servers = {}
2562
+ return servers, raw_config
2563
+
2564
+
2565
+ def _cleanup_c3_artifacts(profile) -> list[str]:
2566
+ """Remove project files/hooks related to C3 registration for the given profile."""
2567
+ removed_files = []
2568
+
2569
+ # Remove instructions file generated for this IDE profile.
2570
+ if getattr(profile, "instructions_file", None):
2571
+ p = PROJECT_PATH / profile.instructions_file
2572
+ if p.exists():
2573
+ p.unlink()
2574
+ removed_files.append(str(p))
2575
+
2576
+ # Claude-only hook and enabled-server cleanup.
2577
+ if profile.name == "claude-code" and profile.settings_path:
2578
+ settings_path = PROJECT_PATH / profile.settings_path
2579
+ if settings_path.exists():
2580
+ try:
2581
+ with open(settings_path, encoding="utf-8") as f:
2582
+ settings = json.load(f)
2583
+ except Exception:
2584
+ settings = {}
2585
+
2586
+ enabled = settings.get("enabledMcpjsonServers", [])
2587
+ if isinstance(enabled, list):
2588
+ settings["enabledMcpjsonServers"] = [x for x in enabled if x != "c3"]
2589
+
2590
+ hooks = settings.get("hooks", {}).get("PostToolUse", [])
2591
+ filtered_hooks = []
2592
+ for item in hooks if isinstance(hooks, list) else []:
2593
+ matcher = item.get("matcher")
2594
+ hlist = item.get("hooks", [])
2595
+ if matcher in ("Bash", "Read"):
2596
+ keep_sub = []
2597
+ for h in hlist if isinstance(hlist, list) else []:
2598
+ cmd = (h or {}).get("command", "")
2599
+ if "hook_filter.py" in cmd or "hook_read.py" in cmd:
2600
+ continue
2601
+ keep_sub.append(h)
2602
+ if keep_sub:
2603
+ item["hooks"] = keep_sub
2604
+ filtered_hooks.append(item)
2605
+ else:
2606
+ filtered_hooks.append(item)
2607
+ settings.setdefault("hooks", {})["PostToolUse"] = filtered_hooks
2608
+
2609
+ with open(settings_path, "w", encoding="utf-8") as f:
2610
+ json.dump(settings, f, indent=2)
2611
+ removed_files.append(str(settings_path))
2612
+
2613
+ return removed_files
2614
+
2615
+
2616
+ @app.route('/api/mcp/status')
2617
+ def api_mcp_status():
2618
+ """Return MCP configuration status for the active IDE profile."""
2619
+ try:
2620
+ ide_name, profile = _resolve_mcp_profile(request.args.get("ide"))
2621
+ except ValueError as e:
2622
+ return jsonify({"error": str(e)}), 400
2623
+ mcp_file = _mcp_config_path_for_profile(profile)
2624
+
2625
+ result = {
2626
+ "configured": False, # c3 entry present in IDE config
2627
+ "active": False, # c3 is not explicitly disabled
2628
+ "config": {"mcpServers": {}},
2629
+ "server_found": False,
2630
+ "mode": load_mcp_config(str(PROJECT_PATH)).get("mode", "direct"),
2631
+ "entrypoint": "",
2632
+ "ide": ide_name,
2633
+ "config_path": str(mcp_file),
2634
+ }
2635
+
2636
+ if not mcp_file.exists():
2637
+ return jsonify(result)
2638
+
2639
+ try:
2640
+ servers, _ = _read_mcp_servers_for_profile(profile, mcp_file)
2641
+
2642
+ result["config"] = {"mcpServers": servers}
2643
+ result["server_found"] = _find_server_script(servers)
2644
+
2645
+ c3_entry = servers.get("c3", {})
2646
+ if isinstance(c3_entry, dict):
2647
+ result["configured"] = True
2648
+ result["active"] = c3_entry.get("enabled", True) is not False
2649
+ args = c3_entry.get("args", [])
2650
+ if isinstance(args, list):
2651
+ for arg in args:
2652
+ if isinstance(arg, str) and arg.endswith(".py"):
2653
+ result["entrypoint"] = arg
2654
+ if arg.endswith("mcp_proxy.py"):
2655
+ result["mode"] = "proxy"
2656
+ elif arg.endswith("mcp_server.py"):
2657
+ result["mode"] = "direct"
2658
+ break
2659
+ except Exception:
2660
+ pass
2661
+
2662
+ return jsonify(result)
2663
+
2664
+
2665
+ @app.route('/api/mcp/install', methods=['POST'])
2666
+ def api_mcp_install():
2667
+ """Install MCP configuration."""
2668
+ try:
2669
+ data = request.get_json(silent=True) or {}
2670
+ ide_name = data.get('ide', 'auto')
2671
+ mcp_mode = data.get('mcp_mode', 'direct')
2672
+ from types import SimpleNamespace
2673
+
2674
+ from cli.c3 import cmd_install_mcp
2675
+ args = SimpleNamespace(project_path=str(PROJECT_PATH), ide=ide_name, mcp_mode=mcp_mode)
2676
+ result = cmd_install_mcp(args)
2677
+ return jsonify({"success": True, "result": str(result)})
2678
+ except Exception as e:
2679
+ return jsonify({"error": str(e)}), 500
2680
+
2681
+
2682
+ @app.route('/api/mcp/servers', methods=['POST'])
2683
+ def api_mcp_add_server():
2684
+ """Add a custom MCP server to the project configuration."""
2685
+ try:
2686
+ data = request.get_json(silent=True) or {}
2687
+ name = data.get('name')
2688
+ command = data.get('command')
2689
+ args = data.get('args', [])
2690
+ env = data.get('env', {})
2691
+ ide_name = data.get('ide', 'auto')
2692
+ enabled = data.get('enabled', True)
2693
+
2694
+ if not name or not command:
2695
+ return jsonify({"error": "Name and command are required"}), 400
2696
+
2697
+ ide_name, profile = _resolve_mcp_profile(ide_name)
2698
+ mcp_file = _mcp_config_path_for_profile(profile)
2699
+ mcp_file.parent.mkdir(parents=True, exist_ok=True)
2700
+
2701
+ if profile.config_format == "toml":
2702
+ # Codex TOML path
2703
+ section = f"{profile.config_key}.{name}"
2704
+ entries = {"command": command, "args": args}
2705
+ if profile.name == "codex":
2706
+ entries["enabled"] = bool(enabled)
2707
+ _upsert_toml_section(mcp_file, section, entries)
2708
+ else:
2709
+ servers, raw_config = _read_mcp_servers_for_profile(profile, mcp_file)
2710
+ server_config = {"command": command, "args": args}
2711
+ if env:
2712
+ server_config["env"] = env
2713
+ if profile.name == "codex":
2714
+ server_config["enabled"] = bool(enabled)
2715
+ servers[name] = server_config
2716
+
2717
+ if not raw_config:
2718
+ raw_config = {}
2719
+ raw_config.setdefault(profile.config_key, {})
2720
+ raw_config[profile.config_key] = servers
2721
+ with open(mcp_file, "w", encoding="utf-8") as f:
2722
+ json.dump(raw_config, f, indent=2)
2723
+
2724
+ return jsonify({"success": True, "ide": ide_name, "config_path": str(mcp_file)})
2725
+ except ValueError as e:
2726
+ return jsonify({"error": str(e)}), 400
2727
+ except Exception as e:
2728
+ return jsonify({"error": str(e)}), 500
2729
+
2730
+ @app.route('/api/mcp/servers/<name>', methods=['DELETE'])
2731
+ def api_mcp_remove_server(name):
2732
+ """Remove a custom MCP server from the project configuration."""
2733
+ try:
2734
+ query_ide = request.args.get("ide")
2735
+ body = request.get_json(silent=True) or {}
2736
+ ide_name = body.get("ide", query_ide or "auto")
2737
+ raw_remove = body.get("remove_files", request.args.get("remove_files", "0"))
2738
+ if isinstance(raw_remove, bool):
2739
+ remove_files = raw_remove
2740
+ else:
2741
+ remove_files = str(raw_remove).strip().lower() in ("1", "true", "yes")
2742
+
2743
+ ide_name, profile = _resolve_mcp_profile(ide_name)
2744
+ mcp_file = _mcp_config_path_for_profile(profile)
2745
+ if not mcp_file.exists():
2746
+ return jsonify({"error": "MCP config not found"}), 404
2747
+
2748
+ removed = False
2749
+ removed_files = []
2750
+ if profile.config_format == "toml":
2751
+ section = f"{profile.config_key}.{name}"
2752
+ removed = _remove_toml_section(mcp_file, section)
2753
+ # Remove empty TOML file if all mcp server sections are gone
2754
+ if mcp_file.exists():
2755
+ remaining, _ = _read_mcp_servers_for_profile(profile, mcp_file)
2756
+ if not remaining:
2757
+ mcp_file.unlink()
2758
+ removed_files.append(str(mcp_file))
2759
+ else:
2760
+ servers, raw_config = _read_mcp_servers_for_profile(profile, mcp_file)
2761
+ if name in servers:
2762
+ del servers[name]
2763
+ removed = True
2764
+ raw_config.setdefault(profile.config_key, {})
2765
+ raw_config[profile.config_key] = servers
2766
+ # If no servers remain, remove config file to fully clean this IDE MCP config.
2767
+ if not servers and len(raw_config.keys()) <= 1:
2768
+ mcp_file.unlink()
2769
+ removed_files.append(str(mcp_file))
2770
+ else:
2771
+ with open(mcp_file, "w", encoding="utf-8") as f:
2772
+ json.dump(raw_config, f, indent=2)
2773
+
2774
+ if not removed:
2775
+ return jsonify({"error": "Server not found"}), 404
2776
+
2777
+ if name == "c3" and remove_files:
2778
+ removed_files.extend(_cleanup_c3_artifacts(profile))
2779
+
2780
+ return jsonify({"success": True, "ide": ide_name, "removed_files": removed_files})
2781
+ except ValueError as e:
2782
+ return jsonify({"error": str(e)}), 400
2783
+ except Exception as e:
2784
+ return jsonify({"error": str(e)}), 500
2785
+
2786
+
2787
+ # ─── API: Conversations ───────────────────────────────────
2788
+ _conv_store = None
2789
+
2790
+
2791
+ def _get_conv_store():
2792
+ global _conv_store
2793
+ if _conv_store is None:
2794
+ _conv_store = runtime.convo_store if runtime and runtime.convo_store else None
2795
+ if _conv_store is None:
2796
+ from services.conversation_store import ConversationStore
2797
+ _conv_store = ConversationStore(str(PROJECT_PATH))
2798
+ return _conv_store
2799
+
2800
+
2801
+ @app.route('/api/conversations')
2802
+ def api_conversations_list():
2803
+ limit = int(request.args.get('limit', 100))
2804
+ store = _get_conv_store()
2805
+ return jsonify(store.list_sessions(limit=limit))
2806
+
2807
+
2808
+ @app.route('/api/conversations/sync')
2809
+ def api_conversations_sync():
2810
+ store = _get_conv_store()
2811
+ source = (request.args.get('source', 'all') or 'all').strip().lower()
2812
+ force_raw = (request.args.get('force', '') or '').strip().lower()
2813
+ force = force_raw in ("1", "true", "yes", "on")
2814
+ result = store.sync(source=source, force=force)
2815
+ configured_ide = load_ide_config(str(PROJECT_PATH))
2816
+ detected_ide = detect_ide(str(PROJECT_PATH))
2817
+ active_ide = configured_ide or detected_ide or "claude-code"
2818
+ if configured_ide == "claude-code" and detected_ide and detected_ide != "claude-code":
2819
+ active_ide = detected_ide
2820
+ result["ide"] = {
2821
+ "configured": configured_ide,
2822
+ "detected": detected_ide,
2823
+ "active": active_ide,
2824
+ "display_name": get_profile(active_ide).display_name,
2825
+ }
2826
+ return jsonify(result)
2827
+
2828
+
2829
+ @app.route('/api/conversations/search')
2830
+ def api_conversations_search():
2831
+ q = request.args.get('q', '').strip()
2832
+ limit = int(request.args.get('limit', 30))
2833
+ session_id = request.args.get('session_id') or None
2834
+ if not q:
2835
+ return jsonify([])
2836
+ store = _get_conv_store()
2837
+ return jsonify(store.search(q, limit=limit, session_id=session_id))
2838
+
2839
+
2840
+ @app.route('/api/conversations/stats')
2841
+ def api_conversations_stats():
2842
+ store = _get_conv_store()
2843
+ return jsonify(store.get_stats())
2844
+
2845
+
2846
+ @app.route('/api/conversations/<session_id>')
2847
+ def api_conversations_get(session_id):
2848
+ store = _get_conv_store()
2849
+ offset = request.args.get('offset')
2850
+ limit = request.args.get('limit')
2851
+
2852
+ try:
2853
+ offset_val = max(0, int(offset)) if offset is not None else 0
2854
+ except Exception:
2855
+ offset_val = 0
2856
+ try:
2857
+ limit_val = int(limit) if limit is not None else None
2858
+ except Exception:
2859
+ limit_val = None
2860
+
2861
+ return jsonify(store.get_session(session_id, offset=offset_val, limit=limit_val))
2862
+
2863
+
2864
+ @app.route('/api/conversations/<session_id>/turn', methods=['POST'])
2865
+ def api_conversations_add_turn(session_id):
2866
+ data = request.json or {}
2867
+ role = data.get('role', 'user')
2868
+ text = data.get('text', '').strip()
2869
+ tool_calls = data.get('tool_calls')
2870
+ source = (data.get('source', 'api') or 'api').strip().lower()
2871
+ if not text:
2872
+ return jsonify({'error': 'text required'}), 400
2873
+ store = _get_conv_store()
2874
+ turn = store.add_turn(session_id, role, text, tool_calls=tool_calls, source=source)
2875
+ return jsonify(turn)
2876
+
2877
+
2878
+ @app.route('/api/conversations/<session_id>/export')
2879
+ def api_conversations_export(session_id):
2880
+ """Export a conversation session as markdown or JSON."""
2881
+ fmt = request.args.get('format', 'markdown')
2882
+ store = _get_conv_store()
2883
+ turns = store.get_session(session_id)
2884
+ sessions = store.list_sessions(limit=500)
2885
+ meta = next((s for s in sessions if s['session_id'] == session_id), {})
2886
+ if fmt == 'json':
2887
+ return jsonify({'session': meta, 'turns': turns})
2888
+ lines = [f"# {meta.get('title', session_id)}", "",
2889
+ f"**Source:** {meta.get('source', '?')} | **Turns:** {len(turns)} | **Date:** {meta.get('started', '')}", ""]
2890
+ for t in turns:
2891
+ label = "User" if t.get('role') == 'user' else "Assistant"
2892
+ lines += [f"## {label}", "", t.get('text', '')]
2893
+ if t.get('tool_calls'):
2894
+ lines += ["", "**Tool calls:**"]
2895
+ lines += [f"- `{tc.get('tool', '?')}` {tc.get('args', '')}" for tc in t['tool_calls']]
2896
+ lines += ["", "---", ""]
2897
+ return jsonify({'markdown': "\n".join(lines), 'title': meta.get('title', session_id)})
2898
+
2899
+
2900
+ # ─── Launch ──────────────────────────────────────────────
2901
+ def find_free_port(start: int = 3333, max_tries: int = 20) -> int:
2902
+ """Return the first free TCP port starting from *start*."""
2903
+ import socket
2904
+ for offset in range(max_tries):
2905
+ port = start + offset
2906
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
2907
+ try:
2908
+ s.bind(('127.0.0.1', port))
2909
+ return port
2910
+ except OSError:
2911
+ continue
2912
+ raise RuntimeError(f"No free port found in range {start}–{start + max_tries - 1}")
2913
+
2914
+
2915
+ def run_server(
2916
+ project_path: str,
2917
+ port: int = 3333,
2918
+ open_browser: bool = True,
2919
+ silent: bool = False,
2920
+ nano: bool = False,
2921
+ ):
2922
+ """Launch the C3 web UI, auto-selecting a free port if the requested one is busy."""
2923
+ init_services(project_path)
2924
+
2925
+ # Try to load existing index
2926
+ if not indexer._load_index():
2927
+ print("Building index for the first time...")
2928
+ result = indexer.build_index()
2929
+ print(f" Indexed {result['files_indexed']} files, {result['chunks_created']} chunks")
2930
+
2931
+ port = find_free_port(port)
2932
+ start_path = "/nano" if nano else ""
2933
+ url = f"http://localhost:{port}{start_path}"
2934
+
2935
+ # Register in global session registry (for project switcher in UI)
2936
+ try:
2937
+ _meta = {}
2938
+ _meta_path = Path(project_path) / ".c3" / "config.json"
2939
+ if _meta_path.exists():
2940
+ import json as _json
2941
+ _meta = _json.loads(_meta_path.read_text(encoding="utf-8")).get("meta", {})
2942
+ _resolved = Path(project_path).resolve()
2943
+ # Fallback chain: custom meta name → folder name → parent/folder for disambiguation
2944
+ _folder = _resolved.name or _resolved.parent.name or "project"
2945
+ _proj_name = (_meta.get("name") or "").strip() or _folder
2946
+ _register_session(port, str(_resolved), _proj_name)
2947
+ atexit.register(_unregister_session, port)
2948
+ except Exception:
2949
+ pass
2950
+
2951
+ # Use ASCII-safe banner (Windows cmd chokes on Unicode box-drawing)
2952
+ print("")
2953
+ print(" +==========================================+")
2954
+ print(" | C3 — Code Context Control UI |")
2955
+ print(f" | {url:<38} |")
2956
+ print(f" | Project: {str(PROJECT_PATH)[:28]:<28} |")
2957
+ print(" +==========================================+")
2958
+ print("")
2959
+ print(" Press Ctrl+C to stop the server.")
2960
+ print("")
2961
+
2962
+ if open_browser and not silent:
2963
+ def _open():
2964
+ time.sleep(1.5)
2965
+ import webbrowser
2966
+ webbrowser.open(url)
2967
+ threading.Thread(target=_open, daemon=True).start()
2968
+
2969
+ if silent:
2970
+ # Suppress Flask/Werkzeug request logging noise (e.g., /api/* per-request lines).
2971
+ logging.getLogger("werkzeug").setLevel(logging.ERROR)
2972
+
2973
+ app.run(host='127.0.0.1', port=port, debug=False)
2974
+
2975
+
2976
+ if __name__ == "__main__":
2977
+ import argparse
2978
+ parser = argparse.ArgumentParser(description="C3 Web UI Server")
2979
+ parser.add_argument("project_path", nargs="?", default=".")
2980
+ parser.add_argument("--port", type=int, default=3333)
2981
+ parser.add_argument("--no-browser", action="store_true")
2982
+ parser.add_argument("--silent", action="store_true")
2983
+ parser.add_argument("--nano", action="store_true")
2984
+ args = parser.parse_args()
2985
+ run_server(args.project_path, args.port, not args.no_browser, args.silent, args.nano)