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
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""OllamaBridge — Ollama cloud API client with Bearer auth for Oracle.
|
|
2
|
+
|
|
3
|
+
Uses the Ollama cloud service (https://ollama.com) by default.
|
|
4
|
+
Falls back to local Ollama (http://localhost:11434) if no API key is set.
|
|
5
|
+
API key can come from config or OLLAMA_API_KEY env var.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.request
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
_ORACLE_CACHE_DIR = Path.home() / ".c3" / "oracle" / "cache" / "llm"
|
|
18
|
+
_TIMEOUT = 30
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _Cache:
|
|
22
|
+
"""Simple disk cache for LLM responses."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, cache_dir: Path = _ORACLE_CACHE_DIR):
|
|
25
|
+
self._dir = cache_dir
|
|
26
|
+
self._dir.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
|
|
28
|
+
def _key(self, prompt: str, model: str, system: str = "", **opts) -> str:
|
|
29
|
+
raw = f"{model}:{system}:{prompt}:{json.dumps(opts, sort_keys=True)}"
|
|
30
|
+
return hashlib.md5(raw.encode()).hexdigest()
|
|
31
|
+
|
|
32
|
+
def get(self, prompt: str, model: str, system: str = "", **opts) -> Optional[str]:
|
|
33
|
+
path = self._dir / f"{self._key(prompt, model, system, **opts)}.json"
|
|
34
|
+
if path.exists():
|
|
35
|
+
try:
|
|
36
|
+
return json.loads(path.read_text(encoding="utf-8")).get("response")
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
def set(self, prompt: str, model: str, response: str, system: str = "", **opts):
|
|
42
|
+
path = self._dir / f"{self._key(prompt, model, system, **opts)}.json"
|
|
43
|
+
try:
|
|
44
|
+
path.write_text(json.dumps({
|
|
45
|
+
"model": model, "prompt": prompt[:200],
|
|
46
|
+
"response": response,
|
|
47
|
+
}, indent=2), encoding="utf-8")
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class OllamaBridge:
|
|
53
|
+
"""Ollama cloud (or local) API client with Bearer token auth.
|
|
54
|
+
|
|
55
|
+
Priority for API key:
|
|
56
|
+
1. Explicit api_key parameter
|
|
57
|
+
2. OLLAMA_API_KEY environment variable
|
|
58
|
+
If neither is set, requests are sent without auth (works for local Ollama).
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
base_url: str = "https://ollama.com",
|
|
64
|
+
model: str = "gemma4:31b-cloud",
|
|
65
|
+
api_key: str = "",
|
|
66
|
+
):
|
|
67
|
+
self.base_url = base_url.rstrip("/")
|
|
68
|
+
self.model = model
|
|
69
|
+
self.api_key = api_key or os.environ.get("OLLAMA_API_KEY", "")
|
|
70
|
+
self._cache = _Cache()
|
|
71
|
+
|
|
72
|
+
def _headers(self) -> dict:
|
|
73
|
+
h = {"Content-Type": "application/json"}
|
|
74
|
+
if self.api_key:
|
|
75
|
+
h["Authorization"] = f"Bearer {self.api_key}"
|
|
76
|
+
return h
|
|
77
|
+
|
|
78
|
+
def _request(self, path: str, data: dict | None = None, timeout: int = _TIMEOUT):
|
|
79
|
+
"""Make an HTTP request to the Ollama API."""
|
|
80
|
+
url = f"{self.base_url}{path}"
|
|
81
|
+
if data is not None:
|
|
82
|
+
payload = json.dumps(data).encode()
|
|
83
|
+
req = urllib.request.Request(url, data=payload, headers=self._headers(), method="POST")
|
|
84
|
+
else:
|
|
85
|
+
req = urllib.request.Request(url, headers=self._headers())
|
|
86
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
87
|
+
return json.loads(resp.read())
|
|
88
|
+
|
|
89
|
+
# ── Availability ──────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
def is_available(self, timeout: int | None = None) -> bool:
|
|
92
|
+
"""Check if Ollama API is reachable."""
|
|
93
|
+
t = timeout or 5
|
|
94
|
+
try:
|
|
95
|
+
self._request("/api/tags", timeout=t)
|
|
96
|
+
return True
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
# Cloud endpoints may not support /api/tags — try a HEAD-style chat
|
|
100
|
+
try:
|
|
101
|
+
url = f"{self.base_url}/api/chat"
|
|
102
|
+
req = urllib.request.Request(url, headers=self._headers(), method="HEAD")
|
|
103
|
+
urllib.request.urlopen(req, timeout=t)
|
|
104
|
+
return True
|
|
105
|
+
except urllib.error.HTTPError:
|
|
106
|
+
# Any HTTP response (even 4xx/5xx) means the server is reachable
|
|
107
|
+
return True
|
|
108
|
+
except Exception:
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
# ── Models ────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
def list_models(self) -> list[str] | None:
|
|
114
|
+
"""Return list of available model names."""
|
|
115
|
+
try:
|
|
116
|
+
data = self._request("/api/tags")
|
|
117
|
+
return [m["name"] for m in data.get("models", [])]
|
|
118
|
+
except Exception:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
def has_model(self, model: str | None = None) -> bool:
|
|
122
|
+
"""Check if a model is available.
|
|
123
|
+
|
|
124
|
+
Cloud models (name contains 'cloud') won't appear in /api/tags,
|
|
125
|
+
so we skip the tags check and rely on verify_model() instead.
|
|
126
|
+
"""
|
|
127
|
+
target = model or self.model
|
|
128
|
+
if "cloud" in target.lower():
|
|
129
|
+
# Cloud models are not listed in /api/tags — can't check there
|
|
130
|
+
return True # defer to verify_model for actual reachability
|
|
131
|
+
models = self.list_models()
|
|
132
|
+
if models is None:
|
|
133
|
+
return False
|
|
134
|
+
return any(target in m or m.startswith(target) for m in models)
|
|
135
|
+
|
|
136
|
+
def verify_model(self, model: str | None = None) -> bool:
|
|
137
|
+
"""Verify a model works by attempting a minimal generation.
|
|
138
|
+
|
|
139
|
+
Cloud models may not appear in /api/tags and may only support
|
|
140
|
+
/api/chat (not /api/generate). Try chat first (works for both
|
|
141
|
+
local and cloud), fall back to generate for legacy endpoints.
|
|
142
|
+
"""
|
|
143
|
+
log = logging.getLogger("oracle.bridge")
|
|
144
|
+
use_model = model or self.model
|
|
145
|
+
is_cloud = "cloud" in use_model.lower()
|
|
146
|
+
# Cloud models need longer timeout for cold start
|
|
147
|
+
timeout = 60 if is_cloud else 20
|
|
148
|
+
|
|
149
|
+
# Try /api/chat first — works for both local and cloud models
|
|
150
|
+
try:
|
|
151
|
+
result = self.chat(
|
|
152
|
+
[{"role": "user", "content": "Reply with only: OK"}],
|
|
153
|
+
model=use_model, max_tokens=4, timeout=timeout,
|
|
154
|
+
)
|
|
155
|
+
if result is not None and len(result.strip()) > 0:
|
|
156
|
+
log.info("Model %s verified via /api/chat", use_model)
|
|
157
|
+
return True
|
|
158
|
+
log.warning("Model %s: /api/chat returned empty response", use_model)
|
|
159
|
+
except Exception as e:
|
|
160
|
+
log.warning("Model %s: /api/chat failed: %s", use_model, e)
|
|
161
|
+
|
|
162
|
+
# Fallback: /api/generate (some local-only or specific cloud models need this)
|
|
163
|
+
try:
|
|
164
|
+
result = self.generate("Reply with only: OK", model=use_model, max_tokens=4, timeout=timeout)
|
|
165
|
+
if result is not None and len(result.strip()) > 0:
|
|
166
|
+
log.info("Model %s verified via /api/generate", use_model)
|
|
167
|
+
return True
|
|
168
|
+
log.warning("Model %s: /api/generate returned empty response", use_model)
|
|
169
|
+
except Exception as e:
|
|
170
|
+
log.warning("Model %s: /api/generate failed: %s", use_model, e)
|
|
171
|
+
|
|
172
|
+
log.error("Model %s: verification FAILED — model may not be available or needs longer timeout", use_model)
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
# ── Generation ────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
def generate(
|
|
178
|
+
self,
|
|
179
|
+
prompt: str,
|
|
180
|
+
system: str = "",
|
|
181
|
+
temperature: float = 0.3,
|
|
182
|
+
max_tokens: int = 1024,
|
|
183
|
+
num_ctx: int = 8192,
|
|
184
|
+
model: str | None = None,
|
|
185
|
+
timeout: int = 120,
|
|
186
|
+
think: bool = True,
|
|
187
|
+
) -> str | None:
|
|
188
|
+
"""Generate text completion via Ollama API."""
|
|
189
|
+
use_model = model or self.model
|
|
190
|
+
options = {"temperature": temperature, "num_predict": max_tokens, "num_ctx": num_ctx}
|
|
191
|
+
|
|
192
|
+
# Check cache
|
|
193
|
+
cached = self._cache.get(prompt, use_model, system, **options)
|
|
194
|
+
if cached:
|
|
195
|
+
return cached
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
body: dict = {
|
|
199
|
+
"model": use_model,
|
|
200
|
+
"prompt": prompt,
|
|
201
|
+
"stream": False,
|
|
202
|
+
"options": options,
|
|
203
|
+
}
|
|
204
|
+
if system:
|
|
205
|
+
body["system"] = system
|
|
206
|
+
if "cloud" in use_model.lower():
|
|
207
|
+
body["think"] = think
|
|
208
|
+
|
|
209
|
+
data = self._request("/api/generate", data=body, timeout=timeout)
|
|
210
|
+
response = data.get("response") or data.get("content")
|
|
211
|
+
if response:
|
|
212
|
+
self._cache.set(prompt, use_model, response, system, **options)
|
|
213
|
+
return response
|
|
214
|
+
except Exception as e:
|
|
215
|
+
logging.getLogger("oracle.bridge").warning("generate(%s) failed: %s", use_model, e)
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
# ── Chat (alternative API) ────────────────────────────
|
|
219
|
+
|
|
220
|
+
def chat(
|
|
221
|
+
self,
|
|
222
|
+
messages: list[dict],
|
|
223
|
+
model: str | None = None,
|
|
224
|
+
temperature: float = 0.3,
|
|
225
|
+
max_tokens: int = 1024,
|
|
226
|
+
num_ctx: int = 16384,
|
|
227
|
+
timeout: int = 120,
|
|
228
|
+
think: bool = True,
|
|
229
|
+
) -> str | None:
|
|
230
|
+
"""Chat completion via Ollama /api/chat endpoint."""
|
|
231
|
+
use_model = model or self.model
|
|
232
|
+
try:
|
|
233
|
+
body = {
|
|
234
|
+
"model": use_model,
|
|
235
|
+
"messages": messages,
|
|
236
|
+
"stream": False,
|
|
237
|
+
"options": {
|
|
238
|
+
"temperature": temperature,
|
|
239
|
+
"num_predict": max_tokens,
|
|
240
|
+
"num_ctx": num_ctx
|
|
241
|
+
},
|
|
242
|
+
}
|
|
243
|
+
if "cloud" in use_model.lower():
|
|
244
|
+
body["think"] = think
|
|
245
|
+
|
|
246
|
+
data = self._request("/api/chat", data=body, timeout=timeout)
|
|
247
|
+
msg = data.get("message", {})
|
|
248
|
+
# Return content if present, otherwise thinking (for R1-style models)
|
|
249
|
+
content = msg.get("content")
|
|
250
|
+
if not content:
|
|
251
|
+
content = msg.get("thinking")
|
|
252
|
+
return content
|
|
253
|
+
except Exception as e:
|
|
254
|
+
logging.getLogger("oracle.bridge").warning("chat(%s) failed: %s", use_model, e)
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
# ── Streaming Chat ────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
def stream_chat(
|
|
260
|
+
self,
|
|
261
|
+
messages: list[dict],
|
|
262
|
+
model: str | None = None,
|
|
263
|
+
temperature: float = 0.3,
|
|
264
|
+
max_tokens: int = 2048,
|
|
265
|
+
num_ctx: int = 16384,
|
|
266
|
+
timeout: int = 120,
|
|
267
|
+
think: bool | None = True,
|
|
268
|
+
):
|
|
269
|
+
"""Streaming chat completion — yields text chunks as they arrive."""
|
|
270
|
+
use_model = model or self.model
|
|
271
|
+
body = {
|
|
272
|
+
"model": use_model,
|
|
273
|
+
"messages": messages,
|
|
274
|
+
"stream": True,
|
|
275
|
+
"options": {
|
|
276
|
+
"temperature": temperature,
|
|
277
|
+
"num_predict": max_tokens,
|
|
278
|
+
"num_ctx": num_ctx,
|
|
279
|
+
},
|
|
280
|
+
}
|
|
281
|
+
if think is not None:
|
|
282
|
+
body["think"] = think
|
|
283
|
+
url = f"{self.base_url}/api/chat"
|
|
284
|
+
payload = json.dumps(body).encode()
|
|
285
|
+
req = urllib.request.Request(
|
|
286
|
+
url, data=payload, headers=self._headers(), method="POST"
|
|
287
|
+
)
|
|
288
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
289
|
+
for line in resp:
|
|
290
|
+
if not line:
|
|
291
|
+
continue
|
|
292
|
+
chunk = json.loads(line.decode("utf-8"))
|
|
293
|
+
msg = chunk.get("message", {})
|
|
294
|
+
# Models with think=True put reasoning in "thinking" field.
|
|
295
|
+
thinking = msg.get("thinking", "")
|
|
296
|
+
if thinking:
|
|
297
|
+
yield ("thinking", thinking)
|
|
298
|
+
content = msg.get("content", "")
|
|
299
|
+
if content:
|
|
300
|
+
yield ("text", content)
|
|
301
|
+
if chunk.get("done"):
|
|
302
|
+
# Final chunk carries token stats from Ollama
|
|
303
|
+
stats = {}
|
|
304
|
+
for key in (
|
|
305
|
+
"total_duration", "load_duration",
|
|
306
|
+
"prompt_eval_count", "prompt_eval_duration",
|
|
307
|
+
"eval_count", "eval_duration",
|
|
308
|
+
):
|
|
309
|
+
if key in chunk:
|
|
310
|
+
stats[key] = chunk[key]
|
|
311
|
+
if stats:
|
|
312
|
+
yield ("stats", stats)
|
|
313
|
+
break
|
|
314
|
+
|
|
315
|
+
# ── Embeddings ────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
def embed(self, text: str, model: str = "nomic-embed-text") -> list[float] | None:
|
|
318
|
+
"""Generate embedding vector."""
|
|
319
|
+
try:
|
|
320
|
+
data = self._request("/api/embed", data={"model": model, "input": text})
|
|
321
|
+
embeddings = data.get("embeddings")
|
|
322
|
+
return embeddings[0] if embeddings else None
|
|
323
|
+
except Exception:
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
def embed_batch(self, texts: list[str], model: str = "nomic-embed-text") -> list[list[float]] | None:
|
|
327
|
+
"""Embed multiple texts in one call."""
|
|
328
|
+
try:
|
|
329
|
+
data = self._request("/api/embed", data={"model": model, "input": texts}, timeout=_TIMEOUT * 3)
|
|
330
|
+
return data.get("embeddings")
|
|
331
|
+
except Exception:
|
|
332
|
+
return None
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Discovers C3 projects via hub API or direct file read."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import urllib.error
|
|
5
|
+
import urllib.request
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_GLOBAL_C3_DIR = Path.home() / ".c3"
|
|
9
|
+
_PROJECTS_FILE = _GLOBAL_C3_DIR / "projects.json"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ProjectScanner:
|
|
13
|
+
"""Discovers registered C3 projects."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, hub_url: str = "http://localhost:3330"):
|
|
16
|
+
self.hub_url = hub_url.rstrip("/")
|
|
17
|
+
|
|
18
|
+
def discover(self) -> list[dict]:
|
|
19
|
+
"""Return list of project dicts with memory metadata.
|
|
20
|
+
|
|
21
|
+
Tries hub API first, falls back to reading ~/.c3/projects.json directly.
|
|
22
|
+
"""
|
|
23
|
+
projects = self._from_hub() or self._from_file()
|
|
24
|
+
return [self._enrich(p) for p in projects]
|
|
25
|
+
|
|
26
|
+
def _from_hub(self) -> list[dict] | None:
|
|
27
|
+
"""Try fetching projects from the hub REST API."""
|
|
28
|
+
try:
|
|
29
|
+
req = urllib.request.Request(f"{self.hub_url}/api/projects")
|
|
30
|
+
with urllib.request.urlopen(req, timeout=2) as resp:
|
|
31
|
+
data = json.loads(resp.read())
|
|
32
|
+
raw = data if isinstance(data, list) else data.get("projects", [])
|
|
33
|
+
return [{
|
|
34
|
+
"path": p.get("path", ""), "name": p.get("name", ""),
|
|
35
|
+
"tags": p.get("tags", []), "notes": p.get("notes", ""),
|
|
36
|
+
"active": p.get("active", False), "ide": p.get("ide", ""),
|
|
37
|
+
} for p in raw if p.get("path")]
|
|
38
|
+
except Exception:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
def _from_file(self) -> list[dict]:
|
|
42
|
+
"""Fallback: read ~/.c3/projects.json directly."""
|
|
43
|
+
try:
|
|
44
|
+
if _PROJECTS_FILE.exists():
|
|
45
|
+
with open(_PROJECTS_FILE, encoding="utf-8") as f:
|
|
46
|
+
data = json.load(f)
|
|
47
|
+
return [{
|
|
48
|
+
"path": p.get("path", ""), "name": p.get("name", ""),
|
|
49
|
+
"tags": p.get("tags", []), "notes": p.get("notes", ""),
|
|
50
|
+
} for p in data.get("projects", []) if p.get("path")]
|
|
51
|
+
except Exception:
|
|
52
|
+
pass
|
|
53
|
+
return []
|
|
54
|
+
|
|
55
|
+
def _enrich(self, project: dict) -> dict:
|
|
56
|
+
"""Add C3 metadata to a project entry."""
|
|
57
|
+
path = Path(project["path"])
|
|
58
|
+
c3_dir = path / ".c3"
|
|
59
|
+
facts_file = c3_dir / "facts" / "facts.json"
|
|
60
|
+
|
|
61
|
+
has_c3 = c3_dir.is_dir()
|
|
62
|
+
has_facts = facts_file.is_file()
|
|
63
|
+
fact_count = 0
|
|
64
|
+
last_modified = None
|
|
65
|
+
|
|
66
|
+
if has_facts:
|
|
67
|
+
try:
|
|
68
|
+
stat = facts_file.stat()
|
|
69
|
+
last_modified = stat.st_mtime
|
|
70
|
+
with open(facts_file, encoding="utf-8") as f:
|
|
71
|
+
facts = json.load(f)
|
|
72
|
+
fact_count = len(facts) if isinstance(facts, list) else 0
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
"path": project["path"],
|
|
78
|
+
"name": project.get("name") or path.name,
|
|
79
|
+
"tags": project.get("tags", []),
|
|
80
|
+
"notes": project.get("notes", ""),
|
|
81
|
+
"active": project.get("active", False),
|
|
82
|
+
"ide": project.get("ide", ""),
|
|
83
|
+
"has_c3": has_c3,
|
|
84
|
+
"has_facts": has_facts,
|
|
85
|
+
"fact_count": fact_count,
|
|
86
|
+
"facts_mtime": last_modified,
|
|
87
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Background daemon that periodically reviews all C3 projects."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import threading
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
from oracle.config import ORACLE_DIR
|
|
9
|
+
from oracle.services.cross_memory import CrossMemory
|
|
10
|
+
from oracle.services.health_checker import HealthChecker
|
|
11
|
+
from oracle.services.insight_engine import InsightEngine
|
|
12
|
+
from oracle.services.memory_reader import MemoryReader
|
|
13
|
+
from oracle.services.memory_writer import MemoryWriter
|
|
14
|
+
from oracle.services.project_scanner import ProjectScanner
|
|
15
|
+
|
|
16
|
+
_STATE_FILE = ORACLE_DIR / "review_state.json"
|
|
17
|
+
_REPORTS_DIR = ORACLE_DIR / "project_reports"
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger("oracle.review")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _load_state() -> dict:
|
|
23
|
+
try:
|
|
24
|
+
if _STATE_FILE.is_file():
|
|
25
|
+
with open(_STATE_FILE, encoding="utf-8") as f:
|
|
26
|
+
return json.load(f)
|
|
27
|
+
except Exception:
|
|
28
|
+
pass
|
|
29
|
+
return {"projects": {}}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _save_state(state: dict):
|
|
33
|
+
ORACLE_DIR.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
with open(_STATE_FILE, "w", encoding="utf-8") as f:
|
|
35
|
+
json.dump(state, f, indent=2)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _save_report(project_path: str, report: dict):
|
|
39
|
+
_REPORTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
import hashlib
|
|
41
|
+
key = hashlib.sha256(project_path.encode()).hexdigest()[:16]
|
|
42
|
+
with open(_REPORTS_DIR / f"{key}.json", "w", encoding="utf-8") as f:
|
|
43
|
+
json.dump(report, f, indent=2)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _load_report(project_path: str) -> dict | None:
|
|
47
|
+
import hashlib
|
|
48
|
+
key = hashlib.sha256(project_path.encode()).hexdigest()[:16]
|
|
49
|
+
rfile = _REPORTS_DIR / f"{key}.json"
|
|
50
|
+
if not rfile.is_file():
|
|
51
|
+
return None
|
|
52
|
+
try:
|
|
53
|
+
with open(rfile, encoding="utf-8") as f:
|
|
54
|
+
return json.load(f)
|
|
55
|
+
except Exception:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ReviewAgent:
|
|
60
|
+
"""Background daemon thread that reviews projects periodically."""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
scanner: ProjectScanner,
|
|
65
|
+
reader: MemoryReader,
|
|
66
|
+
health_checker: HealthChecker,
|
|
67
|
+
insight_engine: InsightEngine,
|
|
68
|
+
cross_memory: CrossMemory,
|
|
69
|
+
writer: MemoryWriter,
|
|
70
|
+
interval: int = 1800,
|
|
71
|
+
federated_graph=None,
|
|
72
|
+
):
|
|
73
|
+
self.scanner = scanner
|
|
74
|
+
self.reader = reader
|
|
75
|
+
self.health_checker = health_checker
|
|
76
|
+
self.insight_engine = insight_engine
|
|
77
|
+
self.cross_memory = cross_memory
|
|
78
|
+
self.writer = writer
|
|
79
|
+
self.interval = interval
|
|
80
|
+
self.federated_graph = federated_graph
|
|
81
|
+
self._stop = threading.Event()
|
|
82
|
+
self._thread: threading.Thread | None = None
|
|
83
|
+
self._state = _load_state()
|
|
84
|
+
self._last_run: str | None = None
|
|
85
|
+
self._running = False
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def status(self) -> dict:
|
|
89
|
+
return {
|
|
90
|
+
"running": self._running,
|
|
91
|
+
"last_run": self._last_run,
|
|
92
|
+
"interval_seconds": self.interval,
|
|
93
|
+
"projects_tracked": len(self._state.get("projects", {})),
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
def start(self):
|
|
97
|
+
if self._thread and self._thread.is_alive():
|
|
98
|
+
return
|
|
99
|
+
self._stop.clear()
|
|
100
|
+
self._thread = threading.Thread(target=self._loop, daemon=True, name="oracle-review")
|
|
101
|
+
self._thread.start()
|
|
102
|
+
self._running = True
|
|
103
|
+
log.info("Review agent started (interval=%ds)", self.interval)
|
|
104
|
+
|
|
105
|
+
def stop(self):
|
|
106
|
+
self._stop.set()
|
|
107
|
+
self._running = False
|
|
108
|
+
if self._thread:
|
|
109
|
+
self._thread.join(timeout=5)
|
|
110
|
+
log.info("Review agent stopped")
|
|
111
|
+
|
|
112
|
+
def run_now(self):
|
|
113
|
+
"""Trigger one immediate review cycle in a background thread."""
|
|
114
|
+
threading.Thread(target=self._review_cycle, daemon=True, name="oracle-review-now").start()
|
|
115
|
+
|
|
116
|
+
def _loop(self):
|
|
117
|
+
self._stop.wait(10) # initial delay
|
|
118
|
+
while not self._stop.is_set():
|
|
119
|
+
try:
|
|
120
|
+
self._review_cycle()
|
|
121
|
+
except Exception as e:
|
|
122
|
+
log.error("Review cycle failed: %s", e)
|
|
123
|
+
self._stop.wait(self.interval)
|
|
124
|
+
|
|
125
|
+
def _review_cycle(self):
|
|
126
|
+
log.info("Starting review cycle")
|
|
127
|
+
projects = self.scanner.discover()
|
|
128
|
+
changed = []
|
|
129
|
+
|
|
130
|
+
for proj in projects:
|
|
131
|
+
path = proj["path"]
|
|
132
|
+
old_mtime = (self._state.get("projects", {}).get(path, {}).get("facts_mtime"))
|
|
133
|
+
current_mtime = proj.get("facts_mtime")
|
|
134
|
+
|
|
135
|
+
if current_mtime and current_mtime != old_mtime:
|
|
136
|
+
changed.append(proj)
|
|
137
|
+
|
|
138
|
+
# Always cache health report
|
|
139
|
+
try:
|
|
140
|
+
report = self.health_checker.check(path)
|
|
141
|
+
_save_report(path, report)
|
|
142
|
+
except Exception as e:
|
|
143
|
+
log.warning("Health check failed for %s: %s", path, e)
|
|
144
|
+
|
|
145
|
+
# Update state
|
|
146
|
+
for proj in projects:
|
|
147
|
+
self._state.setdefault("projects", {})[proj["path"]] = {
|
|
148
|
+
"last_reviewed": datetime.now(timezone.utc).isoformat(),
|
|
149
|
+
"facts_mtime": proj.get("facts_mtime"),
|
|
150
|
+
"fact_count": proj.get("fact_count", 0),
|
|
151
|
+
}
|
|
152
|
+
_save_state(self._state)
|
|
153
|
+
|
|
154
|
+
# Refresh federated graph cache (no auto cross-insights — on-demand only)
|
|
155
|
+
if len(changed) >= 2 and self.federated_graph is not None:
|
|
156
|
+
try:
|
|
157
|
+
all_paths = [p["path"] for p in projects if p.get("has_facts")]
|
|
158
|
+
self.federated_graph.invalidate()
|
|
159
|
+
self.federated_graph.build(all_paths, force=True)
|
|
160
|
+
log.info("Federated graph refreshed (%d projects, %d changed)",
|
|
161
|
+
len(all_paths), len(changed))
|
|
162
|
+
except Exception as e:
|
|
163
|
+
log.warning("Federated graph refresh failed: %s", e)
|
|
164
|
+
|
|
165
|
+
# Auto-suggest consolidation for projects with many facts
|
|
166
|
+
for proj in changed:
|
|
167
|
+
if proj.get("fact_count", 0) > 30:
|
|
168
|
+
try:
|
|
169
|
+
suggestions = self.insight_engine.suggest_consolidation(proj["path"])
|
|
170
|
+
for s in suggestions:
|
|
171
|
+
if s.get("action") == "merge":
|
|
172
|
+
self.writer.suggest(proj["path"], "merge_facts", {
|
|
173
|
+
"survivor_id": s.get("survivor_id"),
|
|
174
|
+
"merge_ids": s.get("fact_ids", []),
|
|
175
|
+
"merged_text": s.get("merged_text", ""),
|
|
176
|
+
})
|
|
177
|
+
elif s.get("action") == "archive":
|
|
178
|
+
self.writer.suggest(proj["path"], "archive_facts", {
|
|
179
|
+
"fact_ids": s.get("fact_ids", []),
|
|
180
|
+
})
|
|
181
|
+
except Exception as e:
|
|
182
|
+
log.warning("Consolidation suggestion failed for %s: %s", proj["path"], e)
|
|
183
|
+
|
|
184
|
+
self._last_run = datetime.now(timezone.utc).isoformat()
|
|
185
|
+
log.info("Review cycle complete: %d projects, %d changed", len(projects), len(changed))
|
|
186
|
+
|
|
187
|
+
def get_report(self, project_path: str) -> dict | None:
|
|
188
|
+
return _load_report(project_path)
|
|
189
|
+
|
|
190
|
+
def review_single(self, project_path: str) -> dict:
|
|
191
|
+
"""Run a manual review for one project. Saves report + updates state."""
|
|
192
|
+
report = self.health_checker.check(project_path)
|
|
193
|
+
_save_report(project_path, report)
|
|
194
|
+
self._state.setdefault("projects", {})[project_path] = {
|
|
195
|
+
"last_reviewed": datetime.now(timezone.utc).isoformat(),
|
|
196
|
+
"facts_mtime": report.get("fact_stats", {}).get("total"),
|
|
197
|
+
"fact_count": report.get("fact_stats", {}).get("total", 0),
|
|
198
|
+
}
|
|
199
|
+
_save_state(self._state)
|
|
200
|
+
return report
|
|
201
|
+
|
|
202
|
+
def get_last_reviewed(self, project_path: str) -> str | None:
|
|
203
|
+
"""Return ISO timestamp of last review for a project."""
|
|
204
|
+
return (self._state.get("projects", {})
|
|
205
|
+
.get(project_path, {})
|
|
206
|
+
.get("last_reviewed"))
|
services/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""C3 Services — Code Compression, Indexing, Session Management, CLAUDE.md Management, Protocol, Activity Log."""
|