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/mcp_proxy.py
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
MCP Proxy - stdio proxy between Claude Code and the C3 MCP server.
|
|
4
|
+
|
|
5
|
+
Dynamically filters tools/list responses based on conversation context
|
|
6
|
+
and injects a sliding window context summary into tool responses.
|
|
7
|
+
|
|
8
|
+
Architecture:
|
|
9
|
+
Claude Code <--stdio--> mcp_proxy.py <--subprocess stdio--> mcp_server.py
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
python cli/mcp_proxy.py --project <path>
|
|
13
|
+
"""
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
# Add parent to path for imports
|
|
22
|
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
23
|
+
|
|
24
|
+
from core.config import load_proxy_config
|
|
25
|
+
from core.ide import get_profile, load_ide_config
|
|
26
|
+
from services.ollama_client import OllamaClient
|
|
27
|
+
from services.proxy_state import ProxyState
|
|
28
|
+
from services.tool_classifier import ToolClassifier
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MCPProxy:
|
|
32
|
+
"""Bidirectional JSON-RPC proxy with optional tool filtering and context injection."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, project_path: str):
|
|
35
|
+
self.project_path = project_path
|
|
36
|
+
self.config = load_proxy_config(project_path)
|
|
37
|
+
self.disabled = self.config.get("PROXY_DISABLE", False)
|
|
38
|
+
self.filtering_enabled = bool(self.config.get("filter_tools", True)) and "all" not in self.config.get("always_visible", [])
|
|
39
|
+
self.context_injection_enabled = bool(self.config.get("inject_context_summary", False))
|
|
40
|
+
self.state_tracking_enabled = self.filtering_enabled or self.context_injection_enabled
|
|
41
|
+
|
|
42
|
+
# Identify the IDE
|
|
43
|
+
self.ide_name = load_ide_config(project_path)
|
|
44
|
+
self.ide_profile = get_profile(self.ide_name)
|
|
45
|
+
|
|
46
|
+
# Initialize Ollama client (optional, for SLM classification)
|
|
47
|
+
self.ollama = None
|
|
48
|
+
if self.filtering_enabled and self.config.get("use_slm", True):
|
|
49
|
+
try:
|
|
50
|
+
self.ollama = OllamaClient()
|
|
51
|
+
except Exception:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
# Tool classifier
|
|
55
|
+
self.classifier = ToolClassifier(
|
|
56
|
+
always_visible=self.config.get("always_visible", ["core"]),
|
|
57
|
+
max_tools=self.config.get("max_tools", 30),
|
|
58
|
+
use_slm=self.filtering_enabled and self.config.get("use_slm", True),
|
|
59
|
+
slm_model=self.config.get("slm_model", "gemma3n:latest"),
|
|
60
|
+
ollama=self.ollama,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Sliding window state
|
|
64
|
+
self.state = ProxyState(
|
|
65
|
+
window_size=self.config.get("context_window_size", 10),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Track pending tool calls: request_id -> {name, args}
|
|
69
|
+
self._pending: dict[str | int, dict] = {}
|
|
70
|
+
|
|
71
|
+
# Cache the full tool list from the server
|
|
72
|
+
self._all_tools: list[dict] = []
|
|
73
|
+
self._last_active_categories: list[str] = ["core"]
|
|
74
|
+
self._last_visible_tool_names: tuple[str, ...] = ()
|
|
75
|
+
self._last_classification_input: tuple[str, tuple[str, ...]] = ("", ())
|
|
76
|
+
|
|
77
|
+
# Metrics
|
|
78
|
+
self._metrics = {
|
|
79
|
+
"started_at": time.time(),
|
|
80
|
+
"messages_forwarded": 0,
|
|
81
|
+
"tools_list_filtered": 0,
|
|
82
|
+
"tools_calls_intercepted": 0,
|
|
83
|
+
"context_injections": 0,
|
|
84
|
+
"list_changed_sent": 0,
|
|
85
|
+
"errors": 0,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# Subprocess
|
|
89
|
+
self._process: asyncio.subprocess.Process | None = None
|
|
90
|
+
|
|
91
|
+
async def run(self):
|
|
92
|
+
"""Main entry point - spawn subprocess and start forwarding."""
|
|
93
|
+
server_script = str(Path(__file__).parent / "mcp_server.py")
|
|
94
|
+
python_exe = sys.executable
|
|
95
|
+
|
|
96
|
+
kwargs = {}
|
|
97
|
+
if sys.platform == "win32":
|
|
98
|
+
import subprocess
|
|
99
|
+
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
|
100
|
+
|
|
101
|
+
self._process = await asyncio.create_subprocess_exec(
|
|
102
|
+
python_exe, server_script,
|
|
103
|
+
"--project", self.project_path,
|
|
104
|
+
stdin=asyncio.subprocess.PIPE,
|
|
105
|
+
stdout=asyncio.subprocess.PIPE,
|
|
106
|
+
stderr=asyncio.subprocess.PIPE,
|
|
107
|
+
**kwargs
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Forward stderr from subprocess to our stderr
|
|
111
|
+
stderr_task = asyncio.create_task(self._forward_stderr())
|
|
112
|
+
|
|
113
|
+
# Bidirectional forwarding
|
|
114
|
+
client_to_server = asyncio.create_task(self._read_client_forward_server())
|
|
115
|
+
server_to_client = asyncio.create_task(self._read_server_forward_client())
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
# Wait for either direction to finish (means subprocess died or stdin closed)
|
|
119
|
+
done, pending = await asyncio.wait(
|
|
120
|
+
[client_to_server, server_to_client, stderr_task],
|
|
121
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
122
|
+
)
|
|
123
|
+
# Cancel remaining tasks
|
|
124
|
+
for task in pending:
|
|
125
|
+
task.cancel()
|
|
126
|
+
except asyncio.CancelledError:
|
|
127
|
+
pass
|
|
128
|
+
finally:
|
|
129
|
+
self._write_metrics()
|
|
130
|
+
if self._process and self._process.returncode is None:
|
|
131
|
+
self._process.terminate()
|
|
132
|
+
try:
|
|
133
|
+
await asyncio.wait_for(self._process.wait(), timeout=5)
|
|
134
|
+
except asyncio.TimeoutError:
|
|
135
|
+
self._process.kill()
|
|
136
|
+
|
|
137
|
+
# ── Client → Server ────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
async def _read_client_forward_server(self):
|
|
140
|
+
"""Read from our stdin (Claude Code), forward to subprocess stdin.
|
|
141
|
+
|
|
142
|
+
Uses a background thread for stdin reads because Windows ProactorEventLoop
|
|
143
|
+
does not support connect_read_pipe on stdin.
|
|
144
|
+
"""
|
|
145
|
+
loop = asyncio.get_running_loop()
|
|
146
|
+
queue: asyncio.Queue[bytes | None] = asyncio.Queue()
|
|
147
|
+
|
|
148
|
+
def _stdin_thread():
|
|
149
|
+
"""Blocking stdin reader running in a daemon thread."""
|
|
150
|
+
try:
|
|
151
|
+
while True:
|
|
152
|
+
line = sys.stdin.buffer.readline()
|
|
153
|
+
if not line:
|
|
154
|
+
loop.call_soon_threadsafe(queue.put_nowait, None)
|
|
155
|
+
break
|
|
156
|
+
loop.call_soon_threadsafe(queue.put_nowait, line)
|
|
157
|
+
except Exception:
|
|
158
|
+
loop.call_soon_threadsafe(queue.put_nowait, None)
|
|
159
|
+
|
|
160
|
+
t = threading.Thread(target=_stdin_thread, daemon=True)
|
|
161
|
+
t.start()
|
|
162
|
+
|
|
163
|
+
while True:
|
|
164
|
+
line = await queue.get()
|
|
165
|
+
if line is None:
|
|
166
|
+
# Client disconnected
|
|
167
|
+
if self._process and self._process.stdin:
|
|
168
|
+
self._process.stdin.close()
|
|
169
|
+
break
|
|
170
|
+
|
|
171
|
+
if not line.strip():
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
msg = json.loads(line)
|
|
176
|
+
msg = self._intercept_client_to_server(msg)
|
|
177
|
+
out = json.dumps(msg, separators=(",", ":")) + "\n"
|
|
178
|
+
except Exception:
|
|
179
|
+
# Passthrough on parse/intercept failure
|
|
180
|
+
out = line.decode("utf-8", errors="replace") + "\n"
|
|
181
|
+
self._metrics["errors"] += 1
|
|
182
|
+
|
|
183
|
+
self._metrics["messages_forwarded"] += 1
|
|
184
|
+
if self._process and self._process.stdin:
|
|
185
|
+
self._process.stdin.write(out.encode("utf-8"))
|
|
186
|
+
await self._process.stdin.drain()
|
|
187
|
+
|
|
188
|
+
# ── Server → Client ────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
async def _read_server_forward_client(self):
|
|
191
|
+
"""Read from subprocess stdout, forward to our stdout (Claude Code)."""
|
|
192
|
+
stdout = self._process.stdout
|
|
193
|
+
buffer = b""
|
|
194
|
+
|
|
195
|
+
while True:
|
|
196
|
+
chunk = await stdout.read(65536)
|
|
197
|
+
if not chunk:
|
|
198
|
+
break
|
|
199
|
+
|
|
200
|
+
buffer += chunk
|
|
201
|
+
while b"\n" in buffer:
|
|
202
|
+
line, buffer = buffer.split(b"\n", 1)
|
|
203
|
+
if not line.strip():
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
msg = json.loads(line)
|
|
208
|
+
extra_msgs = []
|
|
209
|
+
msg, extra_msgs = self._intercept_server_to_client(msg)
|
|
210
|
+
out = json.dumps(msg, separators=(",", ":")) + "\n"
|
|
211
|
+
|
|
212
|
+
# Write the main message
|
|
213
|
+
sys.stdout.buffer.write(out.encode("utf-8"))
|
|
214
|
+
sys.stdout.buffer.flush()
|
|
215
|
+
|
|
216
|
+
# Write any extra messages (e.g., list_changed notifications)
|
|
217
|
+
for extra in extra_msgs:
|
|
218
|
+
extra_out = json.dumps(extra, separators=(",", ":")) + "\n"
|
|
219
|
+
sys.stdout.buffer.write(extra_out.encode("utf-8"))
|
|
220
|
+
sys.stdout.buffer.flush()
|
|
221
|
+
|
|
222
|
+
self._metrics["messages_forwarded"] += 1
|
|
223
|
+
continue
|
|
224
|
+
except Exception:
|
|
225
|
+
self._metrics["errors"] += 1
|
|
226
|
+
|
|
227
|
+
# NEVER passthrough non-JSON to stdout (it breaks MCP handshake in Codex/Claude)
|
|
228
|
+
# Instead, redirect to our stderr so the user can see the log/error
|
|
229
|
+
sys.stderr.buffer.write(b"[proxy:server_stdout] " + line + b"\n")
|
|
230
|
+
sys.stderr.buffer.flush()
|
|
231
|
+
|
|
232
|
+
# ── Stderr forwarding ──────────────────────────────────
|
|
233
|
+
|
|
234
|
+
async def _forward_stderr(self):
|
|
235
|
+
"""Forward subprocess stderr to our stderr."""
|
|
236
|
+
while True:
|
|
237
|
+
line = await self._process.stderr.readline()
|
|
238
|
+
if not line:
|
|
239
|
+
break
|
|
240
|
+
sys.stderr.buffer.write(line)
|
|
241
|
+
sys.stderr.buffer.flush()
|
|
242
|
+
|
|
243
|
+
# ── Interception ───────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
def _intercept_client_to_server(self, msg: dict) -> dict:
|
|
246
|
+
"""Intercept client→server messages. Track tools/call requests."""
|
|
247
|
+
if self.disabled:
|
|
248
|
+
return msg
|
|
249
|
+
|
|
250
|
+
method = msg.get("method")
|
|
251
|
+
if method == "tools/call":
|
|
252
|
+
req_id = msg.get("id")
|
|
253
|
+
params = msg.get("params", {})
|
|
254
|
+
tool_name = params.get("name", "")
|
|
255
|
+
args = params.get("arguments", {})
|
|
256
|
+
if req_id is not None:
|
|
257
|
+
self._pending[req_id] = {"name": tool_name, "args": args}
|
|
258
|
+
self._metrics["tools_calls_intercepted"] += 1
|
|
259
|
+
|
|
260
|
+
return msg
|
|
261
|
+
|
|
262
|
+
def _intercept_server_to_client(self, msg: dict) -> tuple[dict, list[dict]]:
|
|
263
|
+
"""Intercept server→client messages. Filter tools/list, inject context."""
|
|
264
|
+
extra_msgs = []
|
|
265
|
+
|
|
266
|
+
if self.disabled:
|
|
267
|
+
return msg, extra_msgs
|
|
268
|
+
|
|
269
|
+
# ── tools/list response ──────────────────────────
|
|
270
|
+
# This is a response (has "result") to a tools/list request
|
|
271
|
+
result = msg.get("result")
|
|
272
|
+
if result and isinstance(result, dict) and "tools" in result:
|
|
273
|
+
all_tools = result["tools"]
|
|
274
|
+
# Cache the full tool list
|
|
275
|
+
if len(all_tools) > len(self._all_tools):
|
|
276
|
+
self._all_tools = all_tools
|
|
277
|
+
|
|
278
|
+
if not self.filtering_enabled:
|
|
279
|
+
return msg, extra_msgs
|
|
280
|
+
|
|
281
|
+
# Classify and filter
|
|
282
|
+
active = self.classifier.classify(
|
|
283
|
+
self.state.get_recent_tool_names(),
|
|
284
|
+
self.state.get_recent_text(),
|
|
285
|
+
)
|
|
286
|
+
self._last_active_categories = active
|
|
287
|
+
filtered = self.classifier.filter_tools(all_tools, active)
|
|
288
|
+
msg["result"] = {"tools": filtered}
|
|
289
|
+
self._last_visible_tool_names = tuple(sorted(
|
|
290
|
+
t.get("name", "") for t in filtered if isinstance(t, dict)
|
|
291
|
+
))
|
|
292
|
+
self._last_classification_input = (self.state.get_recent_text(), tuple(self.state.get_recent_tool_names()))
|
|
293
|
+
self._metrics["tools_list_filtered"] += 1
|
|
294
|
+
return msg, extra_msgs
|
|
295
|
+
|
|
296
|
+
# ── tools/call response ──────────────────────────
|
|
297
|
+
req_id = msg.get("id")
|
|
298
|
+
if req_id is not None and req_id in self._pending:
|
|
299
|
+
call_info = self._pending.pop(req_id)
|
|
300
|
+
tool_name = call_info["name"]
|
|
301
|
+
args = call_info["args"]
|
|
302
|
+
|
|
303
|
+
# Extract response text for state tracking
|
|
304
|
+
response_text = ""
|
|
305
|
+
if result:
|
|
306
|
+
if isinstance(result, dict):
|
|
307
|
+
content = result.get("content", [])
|
|
308
|
+
if isinstance(content, list):
|
|
309
|
+
for item in content:
|
|
310
|
+
if isinstance(item, dict) and item.get("type") == "text":
|
|
311
|
+
response_text = item.get("text", "")
|
|
312
|
+
break
|
|
313
|
+
elif isinstance(content, str):
|
|
314
|
+
response_text = content
|
|
315
|
+
|
|
316
|
+
if self.state_tracking_enabled:
|
|
317
|
+
self.state.record_tool_call(tool_name, args, response_text)
|
|
318
|
+
|
|
319
|
+
# Inject context summary
|
|
320
|
+
if self.context_injection_enabled and response_text:
|
|
321
|
+
context_line = self.state.get_context_line()
|
|
322
|
+
if context_line:
|
|
323
|
+
# Append context line to the response text
|
|
324
|
+
new_text = response_text + context_line
|
|
325
|
+
if result and isinstance(result, dict):
|
|
326
|
+
content = result.get("content", [])
|
|
327
|
+
if isinstance(content, list):
|
|
328
|
+
for item in content:
|
|
329
|
+
if isinstance(item, dict) and item.get("type") == "text":
|
|
330
|
+
item["text"] = new_text
|
|
331
|
+
break
|
|
332
|
+
self._metrics["context_injections"] += 1
|
|
333
|
+
|
|
334
|
+
if self.state_tracking_enabled:
|
|
335
|
+
self._write_state()
|
|
336
|
+
|
|
337
|
+
if self.filtering_enabled:
|
|
338
|
+
# Avoid redundant re-classification if state hasn't changed
|
|
339
|
+
current_text = self.state.get_recent_text()
|
|
340
|
+
current_tools = tuple(self.state.get_recent_tool_names())
|
|
341
|
+
current_input = (current_text, current_tools)
|
|
342
|
+
|
|
343
|
+
if current_input != self._last_classification_input:
|
|
344
|
+
self._last_classification_input = current_input
|
|
345
|
+
next_active = self.classifier.classify(
|
|
346
|
+
list(current_tools),
|
|
347
|
+
current_text,
|
|
348
|
+
)
|
|
349
|
+
next_visible = tuple(sorted(
|
|
350
|
+
t
|
|
351
|
+
for t in {
|
|
352
|
+
tool.get("name", "")
|
|
353
|
+
for tool in self.classifier.filter_tools(self._all_tools, next_active)
|
|
354
|
+
}
|
|
355
|
+
if t
|
|
356
|
+
))
|
|
357
|
+
if next_visible != self._last_visible_tool_names:
|
|
358
|
+
self._last_active_categories = next_active
|
|
359
|
+
self._last_visible_tool_names = next_visible
|
|
360
|
+
extra_msgs.append({
|
|
361
|
+
"jsonrpc": "2.0",
|
|
362
|
+
"method": "notifications/tools/list_changed",
|
|
363
|
+
})
|
|
364
|
+
self._metrics["list_changed_sent"] += 1
|
|
365
|
+
|
|
366
|
+
return msg, extra_msgs
|
|
367
|
+
|
|
368
|
+
# ── Live State ─────────────────────────────────────────
|
|
369
|
+
|
|
370
|
+
def _write_state(self):
|
|
371
|
+
"""Write live proxy state to .c3/proxy_state.json for UI consumption."""
|
|
372
|
+
try:
|
|
373
|
+
state = {
|
|
374
|
+
"last_updated": time.time(),
|
|
375
|
+
"ide": self.ide_name,
|
|
376
|
+
"ide_display": self.ide_profile.display_name,
|
|
377
|
+
"current_goal": self.state.current_goal,
|
|
378
|
+
"recent_files": list(self.state.recent_files),
|
|
379
|
+
"recent_decisions": list(self.state.recent_decisions),
|
|
380
|
+
"recent_tools": [
|
|
381
|
+
{"name": c["name"], "summary": c["summary"]}
|
|
382
|
+
for c in list(self.state.tool_calls)[-3:]
|
|
383
|
+
],
|
|
384
|
+
"context_line": self.state.get_context_line().strip(),
|
|
385
|
+
"classification_reasons": self.classifier.classification_reasons,
|
|
386
|
+
}
|
|
387
|
+
state_path = Path(self.project_path) / ".c3" / "proxy_state.json"
|
|
388
|
+
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
389
|
+
with open(state_path, "w", encoding="utf-8") as f:
|
|
390
|
+
json.dump(state, f, indent=2)
|
|
391
|
+
except Exception:
|
|
392
|
+
pass
|
|
393
|
+
|
|
394
|
+
# ── Metrics ────────────────────────────────────────────
|
|
395
|
+
|
|
396
|
+
def _write_metrics(self):
|
|
397
|
+
"""Write proxy metrics to .c3/proxy_metrics.json."""
|
|
398
|
+
try:
|
|
399
|
+
self._metrics["uptime_seconds"] = round(
|
|
400
|
+
time.time() - self._metrics["started_at"], 1
|
|
401
|
+
)
|
|
402
|
+
self._metrics["total_tools_available"] = len(self._all_tools)
|
|
403
|
+
self._metrics["active_categories"] = self._last_active_categories
|
|
404
|
+
self._metrics["active_tool_count"] = self.classifier.get_active_tool_count(
|
|
405
|
+
self._last_active_categories
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
metrics_path = Path(self.project_path) / ".c3" / "proxy_metrics.json"
|
|
409
|
+
metrics_path.parent.mkdir(parents=True, exist_ok=True)
|
|
410
|
+
with open(metrics_path, "w", encoding="utf-8") as f:
|
|
411
|
+
json.dump(self._metrics, f, indent=2)
|
|
412
|
+
except Exception:
|
|
413
|
+
pass
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def main():
|
|
417
|
+
import argparse
|
|
418
|
+
|
|
419
|
+
parser = argparse.ArgumentParser(description="C3 MCP Proxy")
|
|
420
|
+
parser.add_argument("--project", required=True, help="Project root path")
|
|
421
|
+
args = parser.parse_args()
|
|
422
|
+
|
|
423
|
+
proxy = MCPProxy(args.project)
|
|
424
|
+
asyncio.run(proxy.run())
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
if __name__ == "__main__":
|
|
428
|
+
main()
|