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
core/__init__.py ADDED
@@ -0,0 +1,75 @@
1
+ """Token counting and measurement utilities."""
2
+ import hashlib
3
+ import re
4
+ from functools import lru_cache
5
+
6
+ # Lazy-loaded tiktoken encoder
7
+ _encoder = None
8
+
9
+ # LRU cache keyed on content hash — avoids redundant tiktoken encodes
10
+ # within a session where the same code chunks are counted repeatedly.
11
+ _TOKEN_CACHE_MAX = 2048
12
+
13
+ @lru_cache(maxsize=_TOKEN_CACHE_MAX)
14
+ def _cached_count(content_hash: str, length: int, text: str) -> int:
15
+ """Count tokens with LRU memoization. length param ensures hash collisions are safe."""
16
+ encoder = _get_encoder()
17
+ if encoder:
18
+ return len(encoder.encode(text))
19
+ tokens = re.findall(r'\w+|[^\w\s]', text)
20
+ count = 0
21
+ for t in tokens:
22
+ if len(t) <= 4:
23
+ count += 1
24
+ else:
25
+ count += max(1, len(t) // 4)
26
+ return count
27
+
28
+ def _get_encoder():
29
+ """Lazy-load tiktoken encoder (one-time cost)."""
30
+ global _encoder
31
+ if _encoder is None:
32
+ try:
33
+ import tiktoken
34
+ _encoder = tiktoken.get_encoding("cl100k_base")
35
+ except (ImportError, Exception):
36
+ _encoder = False # Sentinel: tiktoken unavailable
37
+ return _encoder
38
+
39
+ def count_tokens(text: str) -> int:
40
+ """
41
+ Count tokens using tiktoken (cl100k_base) with LRU caching.
42
+ Repeated calls with identical text hit the cache (~0ms vs ~1-2ms).
43
+ Falls back to heuristic if tiktoken is unavailable.
44
+ """
45
+ if not text:
46
+ return 0
47
+ # For short strings (<50 chars), hashing overhead > tiktoken cost — skip cache
48
+ if len(text) < 50:
49
+ encoder = _get_encoder()
50
+ if encoder:
51
+ return len(encoder.encode(text))
52
+ return len(re.findall(r'\w+|[^\w\s]', text))
53
+ h = hashlib.md5(text.encode("utf-8", errors="replace")).hexdigest()
54
+ return _cached_count(h, len(text), text)
55
+
56
+ def measure_savings(original: str, compressed: str) -> dict:
57
+ """Measure token savings between original and compressed text."""
58
+ orig_tokens = count_tokens(original)
59
+ comp_tokens = count_tokens(compressed)
60
+ saved = orig_tokens - comp_tokens
61
+ pct = (saved / orig_tokens * 100) if orig_tokens > 0 else 0
62
+ return {
63
+ "original_tokens": orig_tokens,
64
+ "compressed_tokens": comp_tokens,
65
+ "saved_tokens": saved,
66
+ "savings_pct": round(pct, 1)
67
+ }
68
+
69
+ def format_token_count(n: int) -> str:
70
+ """Human-readable token count."""
71
+ if n >= 1_000_000:
72
+ return f"{n/1_000_000:.1f}M"
73
+ elif n >= 1_000:
74
+ return f"{n/1_000:.1f}K"
75
+ return str(n)
core/config.py ADDED
@@ -0,0 +1,269 @@
1
+ """Hybrid configuration loader for C3 v2.3 features.
2
+
3
+ Loads the "hybrid" section from .c3/config.json with sensible defaults.
4
+ All three tiers (output filter, router, SLTM) can be independently disabled.
5
+ """
6
+ import json
7
+ from pathlib import Path
8
+
9
+ DEFAULTS = {
10
+ "ollama_base_url": "http://localhost:11434",
11
+ "HYBRID_DISABLE_TIER1": False, # Output filter
12
+ "HYBRID_DISABLE_TIER2": False, # Router
13
+ "HYBRID_DISABLE_SLTM": False, # Vector memory
14
+ "show_context_nudges": True, # Append budget nudge when over threshold
15
+ "prepend_notifications": True, # Prepend agent notifications to tool/chat responses
16
+ # Model assignments
17
+ "embed_model": "nomic-embed-text",
18
+ "filter_model": "gemma3n:latest",
19
+ "summary_model": "gemma3n:latest",
20
+ "simple_qa_model": "deepseek-r1:1.5b",
21
+ "complex_model": "llama3.2:3b",
22
+ # Router params
23
+ "router_log_threshold": 500, # tokens: route to log_summary
24
+ "router_simple_threshold": 100, # tokens: route to simple_qa
25
+ "router_allow_model_fallback": True,
26
+ "router_fallback_models": [], # Optional ordered model fallbacks
27
+ "router_retry_on_empty": True, # Retry with fallback when first model returns no response
28
+ "validate_timeout_seconds": 12, # Hard timeout for c3_validate native syntax checks
29
+ # Filter params
30
+ "filter_llm_threshold": 500, # tokens: trigger LLM pass
31
+ # SLTM params
32
+ "sltm_alpha": 0.5, # TF-IDF weight in hybrid search (1-alpha = vector weight)
33
+ "sltm_min_score": 0.3, # Minimum similarity threshold for VectorStore search
34
+ # Auto-memory: background learning from tool calls
35
+ "auto_memory": {
36
+ "enabled": True, # Set False to disable all auto-extraction
37
+ },
38
+ # Local RAG Pipeline: auto-retrieve project docs on session start
39
+ "rag": {
40
+ "enabled": True,
41
+ "max_precontext_tokens": 400,
42
+ },
43
+ # Edit Ledger: auto-log Edit/Write operations
44
+ "edit_ledger": {
45
+ "enabled": True,
46
+ "tracking_level": "standard", # "minimal", "standard", "detailed"
47
+ "auto_tag": True, # Auto-tag edits from hooks
48
+ },
49
+ # c3_agent compound workflows
50
+ "agent_workflows": {
51
+ "enabled": True,
52
+ "prefetch_max_files": 3, # Max files to auto-compress on search prefetch
53
+ "batch_validate_max_files": 10, # Max files in a single batch validate call
54
+ "compound_max_compress": 5, # Max files to compress in compound workflows
55
+ "delegate_in_workflows": True, # Allow Ollama delegation in compound workflows
56
+ },
57
+ }
58
+
59
+
60
+ def load_hybrid_config(project_path: str) -> dict:
61
+ """Load hybrid config from .c3/config.json, merged with defaults."""
62
+ config_file = Path(project_path) / ".c3" / "config.json"
63
+ hybrid = {}
64
+ if config_file.exists():
65
+ try:
66
+ with open(config_file, encoding="utf-8") as f:
67
+ data = json.load(f)
68
+ hybrid = data.get("hybrid", {})
69
+ except Exception:
70
+ pass
71
+ merged = {**DEFAULTS, **hybrid}
72
+ if "show_savings_footer" not in hybrid and "SHOW_SAVINGS_SUMMARY" in hybrid:
73
+ merged["show_savings_footer"] = bool(hybrid.get("SHOW_SAVINGS_SUMMARY"))
74
+ if "validate_timeout_seconds" not in hybrid and "validate_review_timeout_seconds" in hybrid:
75
+ merged["validate_timeout_seconds"] = int(hybrid.get("validate_review_timeout_seconds") or DEFAULTS["validate_timeout_seconds"])
76
+ merged["SHOW_SAVINGS_SUMMARY"] = bool(merged.get("show_savings_footer", False))
77
+ return merged
78
+
79
+
80
+ # ─── Agent Defaults ────────────────────────────────────────
81
+
82
+ FILE_MEMORY_DEFAULTS = {
83
+ "enabled": True,
84
+ "max_tracked_files": 200,
85
+ "summary_model": "gemma3n:latest",
86
+ "summary_max_tokens": 80,
87
+ "nudge_threshold": 500, # Min chars in Read result to trigger nudge
88
+ "auto_index_on_read": True, # Auto-index files when Read hook fires
89
+ }
90
+
91
+
92
+ # Keep in sync with services/agents.py:create_agents factory defaults.
93
+ # Core (always-on by default): IndexStaleness, FileMemory, EditLedgerEnricher.
94
+ # Everything else defaults off; users opt in via the UI / config.
95
+ AGENT_DEFAULTS = {
96
+ "IndexStaleness": {
97
+ "enabled": True, "interval": 60, "use_ai": False,
98
+ "ai_model": "gemma3n:latest", "warn_threshold": 5, "rebuild_threshold": 15,
99
+ },
100
+ "MemoryPruner": {
101
+ "enabled": False, "interval": 300, "use_ai": True,
102
+ "ai_model": "gemma3n:latest", "embed_model": "nomic-embed-text",
103
+ "similarity_threshold": 0.8,
104
+ },
105
+ "ClaudeMdDrift": {
106
+ "enabled": False, "interval": 120, "use_ai": False, "ai_model": "gemma3n:latest",
107
+ },
108
+ "SessionInsight": {
109
+ "enabled": False, "interval": 600, "use_ai": True,
110
+ "ai_model": "gemma3n:latest", "min_tool_calls": 10,
111
+ },
112
+ "AutonomyPlanner": {
113
+ "enabled": False, "interval": 240, "use_ai": True,
114
+ "ai_model": "gemma3n:latest", "lookback_tool_calls": 30,
115
+ "cooldown_seconds": 600, "min_signal_score": 2, "max_actions": 3,
116
+ },
117
+ "ClaudeMdUpdater": {
118
+ "enabled": False, "interval": 900, "use_ai": True,
119
+ "ai_model": "gemma3n:latest", "auto_apply": True,
120
+ "min_facts_for_promote": 2,
121
+ },
122
+ "FileMemory": {
123
+ "enabled": True, "interval": 120, "use_ai": False,
124
+ "ai_model": "gemma3n:latest", "max_files_per_cycle": 5,
125
+ },
126
+ "DelegateCoach": {
127
+ "enabled": False, "interval": 300, "use_ai": False,
128
+ "ai_model": "gemma3n:latest", "lookback_lines": 100,
129
+ },
130
+ "KeyFileVersion": {
131
+ "enabled": False, "interval": 180, "use_ai": False,
132
+ "ai_model": "gemma3n:latest", "agent_target": "current",
133
+ "max_changes_per_notice": 4,
134
+ },
135
+ "EditLedgerEnricher": {
136
+ "enabled": True, "interval": 10, "use_ai": False,
137
+ },
138
+ }
139
+
140
+
141
+ # ─── Proxy Defaults ────────────────────────────────────────
142
+
143
+ PROXY_DEFAULTS = {
144
+ "always_visible": ["core"], # Categories always visible when proxy filtering is enabled
145
+ "max_tools": 12,
146
+ "filter_tools": True, # True = dynamic filtering by category, False = show all
147
+ "use_slm": True,
148
+ "slm_model": "gemma3n:latest",
149
+ "context_window_size": 10,
150
+ "inject_context_summary": False,
151
+ "PROXY_DISABLE": False,
152
+ }
153
+
154
+
155
+ def load_proxy_config(project_path: str) -> dict:
156
+ """Load proxy config from .c3/config.json, merged with defaults."""
157
+ config_file = Path(project_path) / ".c3" / "config.json"
158
+ proxy = {}
159
+ if config_file.exists():
160
+ try:
161
+ with open(config_file, encoding="utf-8") as f:
162
+ data = json.load(f)
163
+ proxy = data.get("proxy", {})
164
+ except Exception:
165
+ pass
166
+ merged = {**PROXY_DEFAULTS, **proxy}
167
+ always_visible = merged.get("always_visible", ["core"])
168
+ if isinstance(always_visible, str):
169
+ always_visible = [always_visible]
170
+ merged["always_visible"] = always_visible or ["core"]
171
+ return merged
172
+
173
+
174
+ def load_mcp_config(project_path: str) -> dict:
175
+ """Load MCP mode config from .c3/config.json, merged with defaults."""
176
+ config_file = Path(project_path) / ".c3" / "config.json"
177
+ mcp = {}
178
+ if config_file.exists():
179
+ try:
180
+ with open(config_file, encoding="utf-8") as f:
181
+ data = json.load(f)
182
+ mcp = data.get("mcp", {})
183
+ except Exception:
184
+ pass
185
+ mode = str(mcp.get("mode", "direct") or "direct").strip().lower()
186
+ if mode not in {"direct", "proxy"}:
187
+ mode = "direct"
188
+ return {"mode": mode}
189
+
190
+
191
+ # ─── Delegate Defaults ────────────────────────────────────
192
+
193
+ DELEGATE_DEFAULTS = {
194
+ "enabled": True,
195
+ "preferred_model": "", # Empty = auto-select per task type
196
+ "allow_model_fallback": True, # If preferred/default missing, pick best available local model
197
+ "fallback_models": [], # Optional ordered list of fallback model names
198
+ "max_tokens": 512,
199
+ "temperature": 0.3,
200
+ "auto_compress": True, # Auto-compress file_path content as context
201
+ "auto_search": True, # Auto-search index for 'ask' tasks
202
+ "auto_vector_search": True, # Auto-search vector store if available
203
+ "auto_activity_log": True, # Auto-inject recent activity for diagnose
204
+ "search_top_k": 2,
205
+ "max_context_tokens": 1400,
206
+ # Delegation threshold policy
207
+ "threshold_enabled": True, # Token-saving mode: delegate by default once threshold is met
208
+ "threshold_min_total_tokens": 60,
209
+ "threshold_task_types": ["ask", "explain", "summarize", "improve", "docstring"],
210
+ "threshold_force_task_types": ["diagnose", "review", "test"],
211
+ # Per-task model overrides (empty = use preferred_model or task default)
212
+ "summarize_model": "",
213
+ "explain_model": "",
214
+ "docstring_model": "",
215
+ "review_model": "",
216
+ "ask_model": "",
217
+ "test_model": "",
218
+ "diagnose_model": "",
219
+ "improve_model": "",
220
+ # Codex CLI integration (cloud delegate backend)
221
+ "codex_enabled": True, # Master switch — enable Codex as delegate backend
222
+ "codex_default_model": "gpt-5.3-codex-spark", # Default Codex model
223
+ "codex_default_sandbox": "read-only", # Default sandbox: read-only, workspace-write, danger-full-access
224
+ "codex_reasoning_effort": "high", # xhigh, high, medium, low
225
+ "codex_timeout": 90, # Subprocess timeout in seconds (idle watchdog kills MCP hangs at 20s)
226
+ "codex_max_context_tokens": 4000, # Context truncation limit for Codex calls
227
+ "codex_task_types": ["review", "diagnose", "improve", "test"], # Task types auto-routed to Codex
228
+ "codex_verify_edits": False, # EditLedgerEnricher sends diffs to Codex for review
229
+ "codex_memory_bridge": True, # Auto-extract findings from Codex into c3_memory
230
+ # Gemini CLI integration (cloud delegate backend)
231
+ "gemini_enabled": True, # Master switch — enable Gemini as delegate backend
232
+ "gemini_default_model": "gemini-2.5-flash", # Default Gemini model
233
+ "gemini_timeout": 45, # Subprocess timeout in seconds (idle watchdog kills MCP hangs at 15s)
234
+ "gemini_max_context_tokens": 8000, # Context truncation limit (Gemini has large context windows)
235
+ "gemini_task_types": ["review", "diagnose", "improve", "test"], # Tasks auto-routed to Gemini
236
+ "gemini_memory_bridge": True, # Auto-extract findings from Gemini into c3_memory
237
+ }
238
+
239
+
240
+ def load_delegate_config(project_path: str) -> dict:
241
+ """Load delegate config from .c3/config.json, merged with defaults."""
242
+ config_file = Path(project_path) / ".c3" / "config.json"
243
+ delegate = {}
244
+ if config_file.exists():
245
+ try:
246
+ with open(config_file, encoding="utf-8") as f:
247
+ data = json.load(f)
248
+ delegate = data.get("delegate", {})
249
+ except Exception:
250
+ pass
251
+ return {**DELEGATE_DEFAULTS, **delegate}
252
+
253
+
254
+ def load_agent_config(project_path: str) -> dict:
255
+ """Load agent config from .c3/config.json, merged with AGENT_DEFAULTS."""
256
+ config_file = Path(project_path) / ".c3" / "config.json"
257
+ overrides = {}
258
+ if config_file.exists():
259
+ try:
260
+ with open(config_file, encoding="utf-8") as f:
261
+ data = json.load(f)
262
+ overrides = data.get("agents", {})
263
+ except Exception:
264
+ pass
265
+ # Merge per-agent: defaults ← overrides
266
+ result = {}
267
+ for name, defaults in AGENT_DEFAULTS.items():
268
+ result[name] = {**defaults, **overrides.get(name, {})}
269
+ return result
core/ide.py ADDED
@@ -0,0 +1,188 @@
1
+ """IDE Profile Registry for cross-IDE MCP support.
2
+
3
+ Defines profiles for each supported IDE with their config paths, capabilities,
4
+ and instructions file locations. Enables graceful degradation of Claude-specific
5
+ features in non-Claude IDEs.
6
+ """
7
+ import json
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+
13
+ @dataclass
14
+ class IDEProfile:
15
+ """Describes an IDE's MCP configuration requirements and capabilities."""
16
+ name: str # "claude-code", "vscode", "cursor", "codex"
17
+ display_name: str # "Claude Code", "VS Code Copilot"
18
+ config_path: str # ".mcp.json", ".vscode/mcp.json"
19
+ config_key: str # "mcpServers", "servers", or "mcp_servers"
20
+ needs_type_field: bool # VS Code requires "type": "stdio"
21
+ instructions_file: Optional[str] # "CLAUDE.md", ".github/copilot-instructions.md"
22
+ instructions_line_limit: Optional[int] # 200 for Claude, None for others
23
+ supports_hooks: bool # Only Claude Code
24
+ supports_transcripts: bool # Only Claude Code (reads ~/.claude/)
25
+ supports_clear: bool # Only Claude Code has /clear
26
+ settings_path: Optional[str] # ".claude/settings.local.json" or None
27
+ config_format: str = "json" # "json" or "toml"
28
+ config_path_global: bool = False # True = config_path resolved from home dir, not project
29
+ hook_event: str = "PostToolUse" # Claude="PostToolUse", Gemini="AfterTool"
30
+
31
+
32
+ # ─── IDE Profiles ─────────────────────────────────────────
33
+
34
+ PROFILES = {
35
+ "claude-code": IDEProfile(
36
+ name="claude-code",
37
+ display_name="Claude Code",
38
+ config_path=".mcp.json",
39
+ config_key="mcpServers",
40
+ needs_type_field=False,
41
+ instructions_file="CLAUDE.md",
42
+ instructions_line_limit=200,
43
+ supports_hooks=True,
44
+ supports_transcripts=True,
45
+ supports_clear=True,
46
+ settings_path=".claude/settings.local.json",
47
+ ),
48
+ "vscode": IDEProfile(
49
+ name="vscode",
50
+ display_name="VS Code Copilot",
51
+ config_path=".vscode/mcp.json",
52
+ config_key="servers",
53
+ needs_type_field=True,
54
+ instructions_file=".github/copilot-instructions.md",
55
+ instructions_line_limit=None,
56
+ supports_hooks=False,
57
+ supports_transcripts=False,
58
+ supports_clear=False,
59
+ settings_path=None,
60
+ ),
61
+ "cursor": IDEProfile(
62
+ name="cursor",
63
+ display_name="Cursor",
64
+ config_path=".cursor/mcp.json",
65
+ config_key="mcpServers",
66
+ needs_type_field=False,
67
+ instructions_file=".cursorrules",
68
+ instructions_line_limit=None,
69
+ supports_hooks=False,
70
+ supports_transcripts=False,
71
+ supports_clear=False,
72
+ settings_path=None,
73
+ ),
74
+ "codex": IDEProfile(
75
+ name="codex",
76
+ display_name="OpenAI Codex",
77
+ config_path=".codex/config.toml", # project-scoped; global is ~/.codex/config.toml
78
+ config_key="mcp_servers", # TOML: [mcp_servers.<name>]
79
+ needs_type_field=False,
80
+ instructions_file="AGENTS.md",
81
+ instructions_line_limit=None,
82
+ supports_hooks=False,
83
+ supports_transcripts=False,
84
+ supports_clear=False,
85
+ settings_path=None,
86
+ config_format="toml",
87
+ ),
88
+ "gemini": IDEProfile(
89
+ name="gemini",
90
+ display_name="Gemini CLI",
91
+ config_path=".gemini/settings.json", # project-scoped; global is ~/.gemini/settings.json
92
+ config_key="mcpServers",
93
+ needs_type_field=False,
94
+ instructions_file="GEMINI.md",
95
+ instructions_line_limit=None,
96
+ supports_hooks=True,
97
+ supports_transcripts=False,
98
+ supports_clear=False,
99
+ settings_path=".gemini/settings.json", # same file as MCP config
100
+ hook_event="AfterTool",
101
+ ),
102
+ "antigravity": IDEProfile(
103
+ name="antigravity",
104
+ display_name="Google Antigravity",
105
+ config_path=".gemini/antigravity/mcp_config.json", # resolved from home dir
106
+ config_key="mcpServers",
107
+ needs_type_field=False,
108
+ instructions_file="GEMINI.md",
109
+ instructions_line_limit=None,
110
+ supports_hooks=False,
111
+ supports_transcripts=False,
112
+ supports_clear=False,
113
+ settings_path=None,
114
+ config_path_global=True, # ~/.gemini/antigravity/mcp_config.json
115
+ ),
116
+ }
117
+
118
+
119
+ IDE_ALIASES = {
120
+ "claude": "claude-code",
121
+ "claude-code": "claude-code",
122
+ }
123
+
124
+
125
+ def normalize_ide_name(ide_name: str | None) -> str:
126
+ """Normalize external IDE names and aliases to canonical profile keys."""
127
+ raw = (ide_name or "").strip().lower()
128
+ if not raw:
129
+ return "claude-code"
130
+ return IDE_ALIASES.get(raw, raw)
131
+
132
+
133
+ def get_profile(ide_name: str) -> IDEProfile:
134
+ """Get IDE profile by name. Returns claude-code profile if unknown."""
135
+ return PROFILES.get(normalize_ide_name(ide_name), PROFILES["claude-code"])
136
+
137
+
138
+ def detect_ide(project_path: str) -> str:
139
+ """Auto-detect IDE from project directory markers.
140
+ Prioritizes explicit IDE folders over general .mcp.json to avoid mis-detection.
141
+ """
142
+ p = Path(project_path)
143
+
144
+ # 1. Direct config matches (strongest)
145
+ if (p / ".vscode" / "mcp.json").exists():
146
+ return "vscode"
147
+ if (p / ".cursor" / "mcp.json").exists():
148
+ return "cursor"
149
+ if (p / ".codex" / "config.toml").exists():
150
+ return "codex"
151
+ if (p / ".gemini" / "settings.json").exists():
152
+ # Prefer antigravity if its user-global dir already exists
153
+ if (Path.home() / ".gemini" / "antigravity").is_dir():
154
+ return "antigravity"
155
+ return "gemini"
156
+
157
+ # 2. IDE directory markers (even without config yet)
158
+ if (p / ".codex").is_dir():
159
+ return "codex"
160
+ if (p / ".gemini").is_dir():
161
+ if (Path.home() / ".gemini" / "antigravity").is_dir():
162
+ return "antigravity"
163
+ return "gemini"
164
+ if (p / ".vscode").is_dir():
165
+ return "vscode"
166
+ if (p / ".cursor").is_dir():
167
+ return "cursor"
168
+
169
+ # 3. Fallback to Claude markers
170
+ if (p / ".claude").is_dir() or (p / ".mcp.json").exists():
171
+ return "claude-code"
172
+
173
+ return "claude-code"
174
+
175
+
176
+ def load_ide_config(project_path: str) -> str:
177
+ """Read 'ide' from .c3/config.json. Returns 'claude-code' if not set."""
178
+ config_file = Path(project_path) / ".c3" / "config.json"
179
+ if config_file.exists():
180
+ try:
181
+ with open(config_file, encoding="utf-8") as f:
182
+ data = json.load(f)
183
+ ide = normalize_ide_name(data.get("ide", "claude-code"))
184
+ if ide in PROFILES:
185
+ return ide
186
+ except Exception:
187
+ pass
188
+ return "claude-code"
oracle/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Oracle — Cross-project memory management agent for C3."""
oracle/config.py ADDED
@@ -0,0 +1,75 @@
1
+ """Oracle configuration loader."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ ORACLE_DIR = Path.home() / ".c3" / "oracle"
7
+
8
+ CONFIG_FILE = ORACLE_DIR / "config.json"
9
+
10
+ DEFAULTS = {
11
+ "port": 3331,
12
+ "ollama_base_url": "https://ollama.com",
13
+ "ollama_api_key": "",
14
+ "model": "gemma4:31b-cloud",
15
+ "hub_url": "http://localhost:3330",
16
+ "review_interval_seconds": 1800,
17
+ "review_enabled": True,
18
+ "auto_open_browser": True,
19
+ "theme": "dark",
20
+ "max_facts_per_analysis": 100,
21
+ "insight_confidence_threshold": 0.5,
22
+ "log_level": "INFO",
23
+ "federated_graph_ttl_sec": 3600,
24
+ "cross_sim_threshold": 0.75,
25
+ "cross_max_facts_per_project": 200,
26
+ "cross_top_k_neighbors": 3,
27
+ "embedding_model": "nomic-embed-text",
28
+ "agents": [
29
+ {
30
+ "id": "architect",
31
+ "name": "Architect",
32
+ "description": "Expert in system architecture, design patterns, and cross-project structure. Best for high-level analysis.",
33
+ "system_prompt": "You are the Architect. Focus on structural integrity, design patterns, and the big picture. Provide high-level recommendations before diving into code.",
34
+ "model": "gemma4:31b-cloud",
35
+ "active": True
36
+ },
37
+ {
38
+ "id": "code_explorer",
39
+ "name": "Code Explorer",
40
+ "description": "Specializes in deep code analysis, bug hunting, and tracing execution paths.",
41
+ "system_prompt": "You are the Code Explorer. Be incredibly precise, cite specific lines of code, and focus on the technical implementation details. Trace logic thoroughly.",
42
+ "model": "gemma4:31b-cloud",
43
+ "active": True
44
+ },
45
+ {
46
+ "id": "memory_analyst",
47
+ "name": "Memory Analyst",
48
+ "description": "Focuses on analyzing project memory, facts, and insights.",
49
+ "system_prompt": "You are the Memory Analyst. Rely heavily on memory tools and facts to spot trends. Connect current issues to past context.",
50
+ "model": "gemma4:31b-cloud",
51
+ "active": True
52
+ }
53
+ ],
54
+ }
55
+
56
+
57
+ def load_config() -> dict:
58
+ """Load Oracle config, merged with defaults."""
59
+ cfg = dict(DEFAULTS)
60
+ try:
61
+ if CONFIG_FILE.exists():
62
+ with open(CONFIG_FILE, encoding="utf-8") as f:
63
+ cfg.update(json.load(f))
64
+ except Exception:
65
+ pass
66
+ return cfg
67
+
68
+
69
+ def save_config(cfg: dict):
70
+ """Write Oracle config to disk."""
71
+ ORACLE_DIR.mkdir(parents=True, exist_ok=True)
72
+ merged = dict(DEFAULTS)
73
+ merged.update(cfg)
74
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
75
+ json.dump(merged, f, indent=2)