code-context-control 2.28.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli/__init__.py +1 -0
- cli/_hook_utils.py +99 -0
- cli/c3.py +6152 -0
- cli/commands/__init__.py +1 -0
- cli/commands/common.py +312 -0
- cli/commands/parser.py +286 -0
- cli/docs.html +3178 -0
- cli/edits.html +878 -0
- cli/hook_auto_snapshot.py +142 -0
- cli/hook_c3_signal.py +61 -0
- cli/hook_c3read.py +116 -0
- cli/hook_edit_ledger.py +213 -0
- cli/hook_edit_unlock.py +170 -0
- cli/hook_filter.py +130 -0
- cli/hook_ghost_files.py +238 -0
- cli/hook_pretool_enforce.py +334 -0
- cli/hook_read.py +200 -0
- cli/hook_session_stats.py +62 -0
- cli/hook_terse_advisor.py +190 -0
- cli/hub.html +3764 -0
- cli/hub_server.py +1619 -0
- cli/mcp_proxy.py +428 -0
- cli/mcp_server.py +660 -0
- cli/server.py +2985 -0
- cli/tools/__init__.py +4 -0
- cli/tools/_helpers.py +65 -0
- cli/tools/agent.py +1165 -0
- cli/tools/compress.py +215 -0
- cli/tools/delegate.py +1184 -0
- cli/tools/edit.py +313 -0
- cli/tools/edits.py +118 -0
- cli/tools/filter.py +285 -0
- cli/tools/impact.py +163 -0
- cli/tools/memory.py +469 -0
- cli/tools/read.py +224 -0
- cli/tools/search.py +337 -0
- cli/tools/session.py +95 -0
- cli/tools/shell.py +193 -0
- cli/tools/status.py +306 -0
- cli/tools/validate.py +310 -0
- cli/ui/api.js +36 -0
- cli/ui/app.js +207 -0
- cli/ui/components/chat.js +758 -0
- cli/ui/components/dashboard.js +689 -0
- cli/ui/components/edits.js +220 -0
- cli/ui/components/instructions.js +481 -0
- cli/ui/components/memory.js +626 -0
- cli/ui/components/sessions.js +606 -0
- cli/ui/components/settings.js +1404 -0
- cli/ui/components/sidebar.js +156 -0
- cli/ui/icons.js +51 -0
- cli/ui/shared.js +119 -0
- cli/ui/theme.js +22 -0
- cli/ui.html +168 -0
- cli/ui_legacy.html +6797 -0
- cli/ui_nano.html +503 -0
- code_context_control-2.28.0.dist-info/METADATA +248 -0
- code_context_control-2.28.0.dist-info/RECORD +150 -0
- code_context_control-2.28.0.dist-info/WHEEL +5 -0
- code_context_control-2.28.0.dist-info/entry_points.txt +4 -0
- code_context_control-2.28.0.dist-info/licenses/LICENSE +201 -0
- code_context_control-2.28.0.dist-info/top_level.txt +5 -0
- core/__init__.py +75 -0
- core/config.py +269 -0
- core/ide.py +188 -0
- oracle/__init__.py +1 -0
- oracle/config.py +75 -0
- oracle/oracle.html +3900 -0
- oracle/oracle_server.py +663 -0
- oracle/services/__init__.py +1 -0
- oracle/services/c3_bridge.py +210 -0
- oracle/services/chat_engine.py +1103 -0
- oracle/services/chat_store.py +155 -0
- oracle/services/cross_memory.py +154 -0
- oracle/services/federated_graph.py +463 -0
- oracle/services/health_checker.py +117 -0
- oracle/services/insight_engine.py +307 -0
- oracle/services/memory_reader.py +106 -0
- oracle/services/memory_writer.py +182 -0
- oracle/services/ollama_bridge.py +332 -0
- oracle/services/project_scanner.py +87 -0
- oracle/services/review_agent.py +206 -0
- services/__init__.py +1 -0
- services/activity_log.py +93 -0
- services/agent_base.py +124 -0
- services/agents.py +1529 -0
- services/auto_memory.py +407 -0
- services/bench/__init__.py +6 -0
- services/bench/external/__init__.py +29 -0
- services/bench/external/aider_polyglot.py +405 -0
- services/bench/external/swe_bench.py +485 -0
- services/benchmark_dashboard.py +596 -0
- services/claude_md.py +785 -0
- services/compressor.py +592 -0
- services/context_snapshot.py +356 -0
- services/conversation_store.py +870 -0
- services/doc_index.py +537 -0
- services/e2e_benchmark.py +2884 -0
- services/e2e_evaluator.py +396 -0
- services/e2e_tasks.py +743 -0
- services/edit_ledger.py +459 -0
- services/embedding_index.py +341 -0
- services/error_reporting.py +123 -0
- services/file_memory.py +734 -0
- services/hub_service.py +585 -0
- services/indexer.py +712 -0
- services/memory.py +318 -0
- services/memory_consolidator.py +538 -0
- services/memory_graph.py +382 -0
- services/memory_grounder.py +304 -0
- services/memory_scorer.py +246 -0
- services/metrics.py +86 -0
- services/notifications.py +209 -0
- services/ollama_client.py +201 -0
- services/output_filter.py +488 -0
- services/parser.py +1238 -0
- services/project_manager.py +579 -0
- services/protocol.py +306 -0
- services/proxy_state.py +152 -0
- services/retrieval_broker.py +129 -0
- services/router.py +414 -0
- services/runtime.py +326 -0
- services/session_benchmark.py +1945 -0
- services/session_manager.py +1026 -0
- services/session_preloader.py +251 -0
- services/text_index.py +90 -0
- services/tool_classifier.py +176 -0
- services/transcript_index.py +340 -0
- services/validation_cache.py +155 -0
- services/vector_store.py +299 -0
- services/version_tracker.py +271 -0
- services/watcher.py +192 -0
- tui/__init__.py +0 -0
- tui/backend.py +59 -0
- tui/main.py +145 -0
- tui/screens/__init__.py +1 -0
- tui/screens/benchmark_view.py +109 -0
- tui/screens/claudemd_view.py +46 -0
- tui/screens/compress_view.py +52 -0
- tui/screens/index_view.py +74 -0
- tui/screens/init_view.py +82 -0
- tui/screens/mcp_view.py +73 -0
- tui/screens/optimize_view.py +41 -0
- tui/screens/pipe_view.py +46 -0
- tui/screens/projects_view.py +355 -0
- tui/screens/search_view.py +55 -0
- tui/screens/session_view.py +143 -0
- tui/screens/stats.py +158 -0
- tui/screens/ui_view.py +54 -0
- tui/theme.tcss +335 -0
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)
|