code-context-control 2.28.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. cli/__init__.py +1 -0
  2. cli/_hook_utils.py +99 -0
  3. cli/c3.py +6152 -0
  4. cli/commands/__init__.py +1 -0
  5. cli/commands/common.py +312 -0
  6. cli/commands/parser.py +286 -0
  7. cli/docs.html +3178 -0
  8. cli/edits.html +878 -0
  9. cli/hook_auto_snapshot.py +142 -0
  10. cli/hook_c3_signal.py +61 -0
  11. cli/hook_c3read.py +116 -0
  12. cli/hook_edit_ledger.py +213 -0
  13. cli/hook_edit_unlock.py +170 -0
  14. cli/hook_filter.py +130 -0
  15. cli/hook_ghost_files.py +238 -0
  16. cli/hook_pretool_enforce.py +334 -0
  17. cli/hook_read.py +200 -0
  18. cli/hook_session_stats.py +62 -0
  19. cli/hook_terse_advisor.py +190 -0
  20. cli/hub.html +3764 -0
  21. cli/hub_server.py +1619 -0
  22. cli/mcp_proxy.py +428 -0
  23. cli/mcp_server.py +660 -0
  24. cli/server.py +2985 -0
  25. cli/tools/__init__.py +4 -0
  26. cli/tools/_helpers.py +65 -0
  27. cli/tools/agent.py +1165 -0
  28. cli/tools/compress.py +215 -0
  29. cli/tools/delegate.py +1184 -0
  30. cli/tools/edit.py +313 -0
  31. cli/tools/edits.py +118 -0
  32. cli/tools/filter.py +285 -0
  33. cli/tools/impact.py +163 -0
  34. cli/tools/memory.py +469 -0
  35. cli/tools/read.py +224 -0
  36. cli/tools/search.py +337 -0
  37. cli/tools/session.py +95 -0
  38. cli/tools/shell.py +193 -0
  39. cli/tools/status.py +306 -0
  40. cli/tools/validate.py +310 -0
  41. cli/ui/api.js +36 -0
  42. cli/ui/app.js +207 -0
  43. cli/ui/components/chat.js +758 -0
  44. cli/ui/components/dashboard.js +689 -0
  45. cli/ui/components/edits.js +220 -0
  46. cli/ui/components/instructions.js +481 -0
  47. cli/ui/components/memory.js +626 -0
  48. cli/ui/components/sessions.js +606 -0
  49. cli/ui/components/settings.js +1404 -0
  50. cli/ui/components/sidebar.js +156 -0
  51. cli/ui/icons.js +51 -0
  52. cli/ui/shared.js +119 -0
  53. cli/ui/theme.js +22 -0
  54. cli/ui.html +168 -0
  55. cli/ui_legacy.html +6797 -0
  56. cli/ui_nano.html +503 -0
  57. code_context_control-2.28.0.dist-info/METADATA +248 -0
  58. code_context_control-2.28.0.dist-info/RECORD +150 -0
  59. code_context_control-2.28.0.dist-info/WHEEL +5 -0
  60. code_context_control-2.28.0.dist-info/entry_points.txt +4 -0
  61. code_context_control-2.28.0.dist-info/licenses/LICENSE +201 -0
  62. code_context_control-2.28.0.dist-info/top_level.txt +5 -0
  63. core/__init__.py +75 -0
  64. core/config.py +269 -0
  65. core/ide.py +188 -0
  66. oracle/__init__.py +1 -0
  67. oracle/config.py +75 -0
  68. oracle/oracle.html +3900 -0
  69. oracle/oracle_server.py +663 -0
  70. oracle/services/__init__.py +1 -0
  71. oracle/services/c3_bridge.py +210 -0
  72. oracle/services/chat_engine.py +1103 -0
  73. oracle/services/chat_store.py +155 -0
  74. oracle/services/cross_memory.py +154 -0
  75. oracle/services/federated_graph.py +463 -0
  76. oracle/services/health_checker.py +117 -0
  77. oracle/services/insight_engine.py +307 -0
  78. oracle/services/memory_reader.py +106 -0
  79. oracle/services/memory_writer.py +182 -0
  80. oracle/services/ollama_bridge.py +332 -0
  81. oracle/services/project_scanner.py +87 -0
  82. oracle/services/review_agent.py +206 -0
  83. services/__init__.py +1 -0
  84. services/activity_log.py +93 -0
  85. services/agent_base.py +124 -0
  86. services/agents.py +1529 -0
  87. services/auto_memory.py +407 -0
  88. services/bench/__init__.py +6 -0
  89. services/bench/external/__init__.py +29 -0
  90. services/bench/external/aider_polyglot.py +405 -0
  91. services/bench/external/swe_bench.py +485 -0
  92. services/benchmark_dashboard.py +596 -0
  93. services/claude_md.py +785 -0
  94. services/compressor.py +592 -0
  95. services/context_snapshot.py +356 -0
  96. services/conversation_store.py +870 -0
  97. services/doc_index.py +537 -0
  98. services/e2e_benchmark.py +2884 -0
  99. services/e2e_evaluator.py +396 -0
  100. services/e2e_tasks.py +743 -0
  101. services/edit_ledger.py +459 -0
  102. services/embedding_index.py +341 -0
  103. services/error_reporting.py +123 -0
  104. services/file_memory.py +734 -0
  105. services/hub_service.py +585 -0
  106. services/indexer.py +712 -0
  107. services/memory.py +318 -0
  108. services/memory_consolidator.py +538 -0
  109. services/memory_graph.py +382 -0
  110. services/memory_grounder.py +304 -0
  111. services/memory_scorer.py +246 -0
  112. services/metrics.py +86 -0
  113. services/notifications.py +209 -0
  114. services/ollama_client.py +201 -0
  115. services/output_filter.py +488 -0
  116. services/parser.py +1238 -0
  117. services/project_manager.py +579 -0
  118. services/protocol.py +306 -0
  119. services/proxy_state.py +152 -0
  120. services/retrieval_broker.py +129 -0
  121. services/router.py +414 -0
  122. services/runtime.py +326 -0
  123. services/session_benchmark.py +1945 -0
  124. services/session_manager.py +1026 -0
  125. services/session_preloader.py +251 -0
  126. services/text_index.py +90 -0
  127. services/tool_classifier.py +176 -0
  128. services/transcript_index.py +340 -0
  129. services/validation_cache.py +155 -0
  130. services/vector_store.py +299 -0
  131. services/version_tracker.py +271 -0
  132. services/watcher.py +192 -0
  133. tui/__init__.py +0 -0
  134. tui/backend.py +59 -0
  135. tui/main.py +145 -0
  136. tui/screens/__init__.py +1 -0
  137. tui/screens/benchmark_view.py +109 -0
  138. tui/screens/claudemd_view.py +46 -0
  139. tui/screens/compress_view.py +52 -0
  140. tui/screens/index_view.py +74 -0
  141. tui/screens/init_view.py +82 -0
  142. tui/screens/mcp_view.py +73 -0
  143. tui/screens/optimize_view.py +41 -0
  144. tui/screens/pipe_view.py +46 -0
  145. tui/screens/projects_view.py +355 -0
  146. tui/screens/search_view.py +55 -0
  147. tui/screens/session_view.py +143 -0
  148. tui/screens/stats.py +158 -0
  149. tui/screens/ui_view.py +54 -0
  150. tui/theme.tcss +335 -0
cli/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()