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
@@ -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."""