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
oracle/oracle_server.py
ADDED
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
"""Oracle Memory Agent — Flask server + entry point."""
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import socket
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
import urllib.request
|
|
11
|
+
import webbrowser
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
# Ensure project root is on path for imports
|
|
15
|
+
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
16
|
+
if str(_PROJECT_ROOT) not in sys.path:
|
|
17
|
+
sys.path.insert(0, str(_PROJECT_ROOT))
|
|
18
|
+
|
|
19
|
+
from flask import Flask, Response, jsonify, request, send_from_directory # noqa: E402
|
|
20
|
+
|
|
21
|
+
from oracle.config import ORACLE_DIR, load_config, save_config # noqa: E402
|
|
22
|
+
from oracle.services.c3_bridge import C3Bridge # noqa: E402
|
|
23
|
+
from oracle.services.chat_engine import ChatEngine # noqa: E402
|
|
24
|
+
from oracle.services.chat_store import ChatStore # noqa: E402
|
|
25
|
+
from oracle.services.cross_memory import CrossMemory # noqa: E402
|
|
26
|
+
from oracle.services.federated_graph import FederatedGraph # noqa: E402
|
|
27
|
+
from oracle.services.health_checker import HealthChecker # noqa: E402
|
|
28
|
+
from oracle.services.insight_engine import InsightEngine # noqa: E402
|
|
29
|
+
from oracle.services.memory_reader import MemoryReader # noqa: E402
|
|
30
|
+
from oracle.services.memory_writer import MemoryWriter # noqa: E402
|
|
31
|
+
from oracle.services.ollama_bridge import OllamaBridge # noqa: E402
|
|
32
|
+
from oracle.services.project_scanner import ProjectScanner # noqa: E402
|
|
33
|
+
from oracle.services.review_agent import ReviewAgent # noqa: E402
|
|
34
|
+
|
|
35
|
+
# ── App ───────────────────────────────────────────────────
|
|
36
|
+
app = Flask(__name__)
|
|
37
|
+
|
|
38
|
+
# ── Services (initialized at startup) ────────────────────
|
|
39
|
+
_cfg: dict = {}
|
|
40
|
+
_model_verified: bool | None = None # cached result of startup model check
|
|
41
|
+
_bridge: OllamaBridge | None = None
|
|
42
|
+
_scanner: ProjectScanner | None = None
|
|
43
|
+
_reader: MemoryReader | None = None
|
|
44
|
+
_checker: HealthChecker | None = None
|
|
45
|
+
_writer: MemoryWriter | None = None
|
|
46
|
+
_cross_memory: CrossMemory | None = None
|
|
47
|
+
_engine: InsightEngine | None = None
|
|
48
|
+
_agent: ReviewAgent | None = None
|
|
49
|
+
_chat_store: ChatStore | None = None
|
|
50
|
+
_chat_engine: ChatEngine | None = None
|
|
51
|
+
_c3_bridge: C3Bridge | None = None
|
|
52
|
+
_federated: FederatedGraph | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _init_services():
|
|
56
|
+
global _cfg, _bridge, _scanner, _reader, _checker, _writer, _cross_memory, _engine, _agent, _model_verified, _chat_store, _chat_engine, _c3_bridge, _federated
|
|
57
|
+
_cfg = load_config()
|
|
58
|
+
_bridge = OllamaBridge(
|
|
59
|
+
base_url=_cfg.get("ollama_base_url", "https://ollama.com"),
|
|
60
|
+
model=_cfg.get("model", "gemma4:31b-cloud"),
|
|
61
|
+
api_key=_cfg.get("ollama_api_key", ""),
|
|
62
|
+
)
|
|
63
|
+
# Verify model works on startup (background thread to avoid blocking)
|
|
64
|
+
def _verify():
|
|
65
|
+
global _model_verified
|
|
66
|
+
if _bridge.is_available(timeout=5):
|
|
67
|
+
_model_verified = _bridge.verify_model()
|
|
68
|
+
status = "verified" if _model_verified else "FAILED"
|
|
69
|
+
logging.getLogger("oracle").info("Model %s: %s", _bridge.model, status)
|
|
70
|
+
else:
|
|
71
|
+
_model_verified = False
|
|
72
|
+
logging.getLogger("oracle").warning("Ollama unreachable — model not verified")
|
|
73
|
+
threading.Thread(target=_verify, daemon=True, name="oracle-model-verify").start()
|
|
74
|
+
_scanner = ProjectScanner(hub_url=_cfg.get("hub_url", "http://localhost:3330"))
|
|
75
|
+
_reader = MemoryReader()
|
|
76
|
+
_checker = HealthChecker(_reader)
|
|
77
|
+
_writer = MemoryWriter()
|
|
78
|
+
_cross_memory = CrossMemory()
|
|
79
|
+
_engine = InsightEngine(_bridge, _reader, _cross_memory)
|
|
80
|
+
_chat_store = ChatStore()
|
|
81
|
+
_c3_bridge = C3Bridge(scanner=_scanner)
|
|
82
|
+
_federated = FederatedGraph(reader=_reader, cross_memory=_cross_memory, ollama_bridge=_bridge)
|
|
83
|
+
_agent = ReviewAgent(
|
|
84
|
+
scanner=_scanner,
|
|
85
|
+
reader=_reader,
|
|
86
|
+
health_checker=_checker,
|
|
87
|
+
insight_engine=_engine,
|
|
88
|
+
cross_memory=_cross_memory,
|
|
89
|
+
writer=_writer,
|
|
90
|
+
interval=int(_cfg.get("review_interval_seconds", 1800)),
|
|
91
|
+
federated_graph=_federated,
|
|
92
|
+
)
|
|
93
|
+
_chat_engine = ChatEngine(
|
|
94
|
+
bridge=_bridge,
|
|
95
|
+
reader=_reader,
|
|
96
|
+
writer=_writer,
|
|
97
|
+
cross_memory=_cross_memory,
|
|
98
|
+
health_checker=_checker,
|
|
99
|
+
insight_engine=_engine,
|
|
100
|
+
scanner=_scanner,
|
|
101
|
+
store=_chat_store,
|
|
102
|
+
c3_bridge=_c3_bridge,
|
|
103
|
+
)
|
|
104
|
+
atexit.register(lambda: _c3_bridge.shutdown() if _c3_bridge else None)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ── CORS ──────────────────────────────────────────────────
|
|
108
|
+
@app.after_request
|
|
109
|
+
def _cors(resp):
|
|
110
|
+
resp.headers["Access-Control-Allow-Origin"] = "*"
|
|
111
|
+
resp.headers["Access-Control-Allow-Headers"] = "Content-Type"
|
|
112
|
+
resp.headers["Access-Control-Allow-Methods"] = "GET,POST,PUT,DELETE,OPTIONS"
|
|
113
|
+
return resp
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ── Static ────────────────────────────────────────────────
|
|
117
|
+
@app.route("/")
|
|
118
|
+
def index():
|
|
119
|
+
return send_from_directory(os.path.dirname(__file__), "oracle.html")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ── Health ────────────────────────────────────────────────
|
|
123
|
+
@app.route("/api/health")
|
|
124
|
+
def api_health():
|
|
125
|
+
ollama_ok = _bridge.is_available(timeout=3) if _bridge else False
|
|
126
|
+
hub_ok = False
|
|
127
|
+
try:
|
|
128
|
+
hub_url = _cfg.get("hub_url", "http://localhost:3330").rstrip("/")
|
|
129
|
+
req = urllib.request.Request(f"{hub_url}/api/health")
|
|
130
|
+
with urllib.request.urlopen(req, timeout=2) as r:
|
|
131
|
+
hub_ok = json.loads(r.read()).get("service") == "c3-hub"
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
return jsonify({
|
|
135
|
+
"status": "ok",
|
|
136
|
+
"service": "c3-oracle",
|
|
137
|
+
"model": _cfg.get("model", "gemma4:31b-cloud"),
|
|
138
|
+
"ollama_available": ollama_ok,
|
|
139
|
+
"model_verified": _model_verified,
|
|
140
|
+
"hub_available": hub_ok,
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ── Config ────────────────────────────────────────────────
|
|
145
|
+
@app.route("/api/config", methods=["GET"])
|
|
146
|
+
def api_config_get():
|
|
147
|
+
cfg = load_config()
|
|
148
|
+
# Mask API key in response — only show if set
|
|
149
|
+
if cfg.get("ollama_api_key"):
|
|
150
|
+
cfg["ollama_api_key"] = cfg["ollama_api_key"][:4] + "••••"
|
|
151
|
+
return jsonify(cfg)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@app.route("/api/config", methods=["POST"])
|
|
155
|
+
def api_config_set():
|
|
156
|
+
global _cfg
|
|
157
|
+
body = request.get_json(silent=True) or {}
|
|
158
|
+
cfg = load_config()
|
|
159
|
+
cfg.update(body)
|
|
160
|
+
save_config(cfg)
|
|
161
|
+
_cfg = cfg
|
|
162
|
+
# Update bridge if config changed
|
|
163
|
+
re_verify = False
|
|
164
|
+
if _bridge:
|
|
165
|
+
if "model" in body:
|
|
166
|
+
_bridge.model = cfg["model"]
|
|
167
|
+
re_verify = True
|
|
168
|
+
if "ollama_api_key" in body:
|
|
169
|
+
_bridge.api_key = cfg["ollama_api_key"]
|
|
170
|
+
re_verify = True
|
|
171
|
+
if "ollama_base_url" in body:
|
|
172
|
+
_bridge.base_url = cfg["ollama_base_url"].rstrip("/")
|
|
173
|
+
re_verify = True
|
|
174
|
+
if re_verify and _bridge:
|
|
175
|
+
def _rv():
|
|
176
|
+
global _model_verified
|
|
177
|
+
_model_verified = _bridge.verify_model()
|
|
178
|
+
threading.Thread(target=_rv, daemon=True).start()
|
|
179
|
+
return jsonify({"saved": True, "config": cfg})
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ── Projects ──────────────────────────────────────────────
|
|
183
|
+
@app.route("/api/projects")
|
|
184
|
+
def api_projects():
|
|
185
|
+
projects = _scanner.discover() if _scanner else []
|
|
186
|
+
# Attach cached health status + last_reviewed timestamp
|
|
187
|
+
for p in projects:
|
|
188
|
+
report = _agent.get_report(p["path"]) if _agent else None
|
|
189
|
+
p["health_status"] = report.get("status", "unknown") if report else "unknown"
|
|
190
|
+
p["health_issues"] = len(report.get("issues", [])) if report else 0
|
|
191
|
+
p["last_reviewed"] = _agent.get_last_reviewed(p["path"]) if _agent else None
|
|
192
|
+
return jsonify(projects)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@app.route("/api/projects/scan", methods=["POST"])
|
|
196
|
+
def api_projects_scan():
|
|
197
|
+
projects = _scanner.discover() if _scanner else []
|
|
198
|
+
return jsonify({"scanned": len(projects), "projects": projects})
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@app.route("/api/projects/review", methods=["POST"])
|
|
202
|
+
def api_projects_review():
|
|
203
|
+
body = request.get_json(silent=True) or {}
|
|
204
|
+
path = body.get("path", "")
|
|
205
|
+
if not path:
|
|
206
|
+
return jsonify({"error": "path required"}), 400
|
|
207
|
+
# Run health check synchronously + save report + update state
|
|
208
|
+
if _agent:
|
|
209
|
+
report = _agent.review_single(path)
|
|
210
|
+
elif _checker:
|
|
211
|
+
report = _checker.check(path)
|
|
212
|
+
else:
|
|
213
|
+
report = {"error": "not initialized"}
|
|
214
|
+
# Run LLM analysis in background
|
|
215
|
+
if _engine:
|
|
216
|
+
def _analyze():
|
|
217
|
+
try:
|
|
218
|
+
_engine.analyze_project(path)
|
|
219
|
+
except Exception:
|
|
220
|
+
pass
|
|
221
|
+
threading.Thread(target=_analyze, daemon=True).start()
|
|
222
|
+
return jsonify(report)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@app.route("/api/projects/health")
|
|
226
|
+
def api_projects_health():
|
|
227
|
+
path = request.args.get("path", "")
|
|
228
|
+
if not path:
|
|
229
|
+
return jsonify({"error": "path required"}), 400
|
|
230
|
+
# Try cached report first
|
|
231
|
+
report = _agent.get_report(path) if _agent else None
|
|
232
|
+
if not report:
|
|
233
|
+
report = _checker.check(path) if _checker else {"error": "not initialized"}
|
|
234
|
+
return jsonify(report)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@app.route("/api/projects/facts")
|
|
238
|
+
def api_projects_facts():
|
|
239
|
+
path = request.args.get("path", "")
|
|
240
|
+
limit = int(request.args.get("limit", 50))
|
|
241
|
+
if not path:
|
|
242
|
+
return jsonify({"error": "path required"}), 400
|
|
243
|
+
stats = _reader.get_fact_stats(path) if _reader else {}
|
|
244
|
+
facts = _reader.read_facts(path) if _reader else []
|
|
245
|
+
# Sort by relevance, limit
|
|
246
|
+
facts.sort(key=lambda f: int(f.get("relevance_count", 0)), reverse=True)
|
|
247
|
+
return jsonify({"stats": stats, "facts": facts[:limit]})
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@app.route("/api/projects/graph")
|
|
251
|
+
def api_projects_graph():
|
|
252
|
+
path = request.args.get("path", "")
|
|
253
|
+
if not path:
|
|
254
|
+
return jsonify({"error": "path required"}), 400
|
|
255
|
+
stats = _reader.get_graph_stats(path) if _reader else {}
|
|
256
|
+
return jsonify(stats)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ── Insights ──────────────────────────────────────────────
|
|
260
|
+
@app.route("/api/insights")
|
|
261
|
+
def api_insights():
|
|
262
|
+
if not _cross_memory:
|
|
263
|
+
return jsonify([])
|
|
264
|
+
return jsonify({
|
|
265
|
+
"insights": _cross_memory.get_all_insights(),
|
|
266
|
+
"stats": _cross_memory.stats(),
|
|
267
|
+
"links": _cross_memory.get_project_links(),
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@app.route("/api/insights/project")
|
|
272
|
+
def api_insights_project():
|
|
273
|
+
path = request.args.get("path", "")
|
|
274
|
+
if not path or not _cross_memory:
|
|
275
|
+
return jsonify([])
|
|
276
|
+
return jsonify(_cross_memory.get_for_project(path))
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@app.route("/api/insights/generate", methods=["POST"])
|
|
280
|
+
def api_insights_generate():
|
|
281
|
+
if not _engine or not _scanner:
|
|
282
|
+
return jsonify({"error": "not initialized"}), 500
|
|
283
|
+
projects = _scanner.discover()
|
|
284
|
+
paths = [p["path"] for p in projects if p.get("has_facts")]
|
|
285
|
+
if len(paths) < 2:
|
|
286
|
+
return jsonify({"error": "Need at least 2 projects with facts", "available": len(paths)}), 400
|
|
287
|
+
insights = _engine.find_cross_project_links(paths)
|
|
288
|
+
return jsonify({"generated": len(insights), "insights": insights})
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@app.route("/api/graph/federated")
|
|
292
|
+
def api_graph_federated():
|
|
293
|
+
"""Return federated memory graph across projects."""
|
|
294
|
+
if not _federated or not _scanner:
|
|
295
|
+
return jsonify({"error": "not initialized"}), 500
|
|
296
|
+
projects_param = request.args.get("projects", "")
|
|
297
|
+
if projects_param:
|
|
298
|
+
paths = [p for p in projects_param.split(",") if p]
|
|
299
|
+
else:
|
|
300
|
+
paths = [p["path"] for p in _scanner.discover() if p.get("has_facts")]
|
|
301
|
+
min_sim = request.args.get("min_sim", type=float)
|
|
302
|
+
top_k = request.args.get("top_k", type=int)
|
|
303
|
+
force = request.args.get("force", "0") == "1"
|
|
304
|
+
try:
|
|
305
|
+
return jsonify(_federated.build(paths, force=force, min_sim=min_sim, top_k=top_k))
|
|
306
|
+
except Exception as e:
|
|
307
|
+
return jsonify({"error": str(e)}), 500
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@app.route("/api/graph/federated/node/<path:node_id>")
|
|
311
|
+
def api_graph_federated_node(node_id):
|
|
312
|
+
"""Return a single federated node's fact + cross-project neighbors."""
|
|
313
|
+
if not _federated:
|
|
314
|
+
return jsonify({"error": "not initialized"}), 500
|
|
315
|
+
data = _federated.build([p["path"] for p in _scanner.discover() if p.get("has_facts")])
|
|
316
|
+
node = next((n for n in data.get("nodes", []) if n["id"] == node_id), None)
|
|
317
|
+
if not node:
|
|
318
|
+
return jsonify({"error": "not found"}), 404
|
|
319
|
+
neighbors = []
|
|
320
|
+
nodes_by_id = {n["id"]: n for n in data["nodes"]}
|
|
321
|
+
for e in data.get("edges", []):
|
|
322
|
+
if e["src"] == node_id or e["dst"] == node_id:
|
|
323
|
+
other_id = e["dst"] if e["src"] == node_id else e["src"]
|
|
324
|
+
other = nodes_by_id.get(other_id)
|
|
325
|
+
neighbors.append({
|
|
326
|
+
"id": other_id,
|
|
327
|
+
"type": e["type"],
|
|
328
|
+
"scope": e.get("scope"),
|
|
329
|
+
"weight": e.get("weight"),
|
|
330
|
+
"label": (other["label"] if other else other_id),
|
|
331
|
+
"project": other.get("project") if other else None,
|
|
332
|
+
})
|
|
333
|
+
return jsonify({"node": node, "neighbors": neighbors})
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
@app.route("/api/graph/federated/rebuild", methods=["POST"])
|
|
337
|
+
def api_graph_federated_rebuild():
|
|
338
|
+
if not _federated or not _scanner:
|
|
339
|
+
return jsonify({"error": "not initialized"}), 500
|
|
340
|
+
body = request.get_json(silent=True) or {}
|
|
341
|
+
paths = body.get("projects") or [p["path"] for p in _scanner.discover() if p.get("has_facts")]
|
|
342
|
+
_federated.invalidate()
|
|
343
|
+
return jsonify(_federated.build(paths, force=True))
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@app.route("/api/graph/federated/stats")
|
|
347
|
+
def api_graph_federated_stats():
|
|
348
|
+
if not _federated or not _scanner:
|
|
349
|
+
return jsonify({"error": "not initialized"}), 500
|
|
350
|
+
paths = [p["path"] for p in _scanner.discover() if p.get("has_facts")]
|
|
351
|
+
data = _federated.build(paths)
|
|
352
|
+
return jsonify({"stats": data.get("stats", {}), "projects": data.get("projects", [])})
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
@app.route("/api/insights/cross", methods=["POST"])
|
|
356
|
+
def api_insights_cross():
|
|
357
|
+
"""On-demand cross-project insight generation from federated graph."""
|
|
358
|
+
if not _engine or not _scanner or not _federated:
|
|
359
|
+
return jsonify({"error": "not initialized"}), 500
|
|
360
|
+
body = request.get_json(silent=True) or {}
|
|
361
|
+
paths = body.get("projects") or [p["path"] for p in _scanner.discover() if p.get("has_facts")]
|
|
362
|
+
if len(paths) < 2:
|
|
363
|
+
return jsonify({"error": "Need at least 2 projects with facts", "available": len(paths)}), 400
|
|
364
|
+
fed = _federated.build(paths)
|
|
365
|
+
try:
|
|
366
|
+
if hasattr(_engine, "generate_cross_project_insights"):
|
|
367
|
+
insights = _engine.generate_cross_project_insights(paths, federated_graph=fed)
|
|
368
|
+
else:
|
|
369
|
+
insights = _engine.find_cross_project_links(paths)
|
|
370
|
+
return jsonify({"generated": len(insights), "insights": insights,
|
|
371
|
+
"graph_stats": fed.get("stats", {})})
|
|
372
|
+
except Exception as e:
|
|
373
|
+
return jsonify({"error": str(e)}), 500
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@app.route("/api/insights/dismiss", methods=["POST"])
|
|
377
|
+
def api_insights_dismiss():
|
|
378
|
+
body = request.get_json(silent=True) or {}
|
|
379
|
+
iid = body.get("id", "")
|
|
380
|
+
if not iid or not _cross_memory:
|
|
381
|
+
return jsonify({"error": "id required"}), 400
|
|
382
|
+
return jsonify(_cross_memory.dismiss(iid))
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# ── Suggestions ───────────────────────────────────────────
|
|
386
|
+
@app.route("/api/suggestions")
|
|
387
|
+
def api_suggestions():
|
|
388
|
+
path = request.args.get("path")
|
|
389
|
+
return jsonify(_writer.list_pending(path) if _writer else [])
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@app.route("/api/suggestions/approve", methods=["POST"])
|
|
393
|
+
def api_suggestions_approve():
|
|
394
|
+
body = request.get_json(silent=True) or {}
|
|
395
|
+
sid = body.get("id", "")
|
|
396
|
+
if not sid or not _writer:
|
|
397
|
+
return jsonify({"error": "id required"}), 400
|
|
398
|
+
return jsonify(_writer.approve_suggestion(sid))
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@app.route("/api/suggestions/dismiss", methods=["POST"])
|
|
402
|
+
def api_suggestions_dismiss():
|
|
403
|
+
body = request.get_json(silent=True) or {}
|
|
404
|
+
sid = body.get("id", "")
|
|
405
|
+
if not sid or not _writer:
|
|
406
|
+
return jsonify({"error": "id required"}), 400
|
|
407
|
+
return jsonify(_writer.dismiss_suggestion(sid))
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
# ── Review Agent ──────────────────────────────────────────
|
|
411
|
+
@app.route("/api/review/status")
|
|
412
|
+
def api_review_status():
|
|
413
|
+
return jsonify(_agent.status if _agent else {"running": False})
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@app.route("/api/review/start", methods=["POST"])
|
|
417
|
+
def api_review_start():
|
|
418
|
+
if _agent:
|
|
419
|
+
_agent.start()
|
|
420
|
+
return jsonify({"started": True})
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
@app.route("/api/review/stop", methods=["POST"])
|
|
424
|
+
def api_review_stop():
|
|
425
|
+
if _agent:
|
|
426
|
+
_agent.stop()
|
|
427
|
+
return jsonify({"stopped": True})
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
@app.route("/api/review/run-now", methods=["POST"])
|
|
431
|
+
def api_review_run_now():
|
|
432
|
+
if _agent:
|
|
433
|
+
_agent.run_now()
|
|
434
|
+
return jsonify({"triggered": True})
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# ── Ollama ────────────────────────────────────────────────
|
|
438
|
+
@app.route("/api/ollama/status")
|
|
439
|
+
def api_ollama_status():
|
|
440
|
+
if not _bridge:
|
|
441
|
+
return jsonify({"available": False, "models": []})
|
|
442
|
+
available = _bridge.is_available(timeout=3)
|
|
443
|
+
models = _bridge.list_models() if available else []
|
|
444
|
+
in_tags = _bridge.has_model() if available else False
|
|
445
|
+
return jsonify({
|
|
446
|
+
"available": available,
|
|
447
|
+
"models": models or [],
|
|
448
|
+
"current_model": _cfg.get("model", "gemma4:31b-cloud"),
|
|
449
|
+
"has_model": in_tags,
|
|
450
|
+
"model_verified": _model_verified, # None = still verifying, True/False = result
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
@app.route("/api/ollama/test", methods=["POST"])
|
|
455
|
+
def api_ollama_test():
|
|
456
|
+
if not _bridge:
|
|
457
|
+
return jsonify({"error": "not initialized"}), 500
|
|
458
|
+
|
|
459
|
+
# Try chat first (most robust for cloud/local)
|
|
460
|
+
result = _bridge.chat([{"role": "user", "content": "Say 'Oracle is online' in exactly 4 words."}], max_tokens=32)
|
|
461
|
+
|
|
462
|
+
# Fallback to generate for legacy local models
|
|
463
|
+
if not result:
|
|
464
|
+
result = _bridge.generate("Say 'Oracle is online' in exactly 4 words.", max_tokens=32)
|
|
465
|
+
|
|
466
|
+
return jsonify({"response": result or "No response — check Ollama and model availability"})
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
# ── Chat ──────────────────────────────────────────────────
|
|
470
|
+
|
|
471
|
+
@app.route("/api/chat", methods=["POST"])
|
|
472
|
+
def api_chat():
|
|
473
|
+
"""Streaming chat with Oracle via SSE."""
|
|
474
|
+
if not _chat_engine:
|
|
475
|
+
return jsonify({"error": "not initialized"}), 500
|
|
476
|
+
data = request.get_json() or {}
|
|
477
|
+
message = data.get("message", "").strip()
|
|
478
|
+
conv_id = data.get("conversation_id") or None
|
|
479
|
+
if not message:
|
|
480
|
+
return jsonify({"error": "No message provided"}), 400
|
|
481
|
+
|
|
482
|
+
def generate():
|
|
483
|
+
for event in _chat_engine.chat(conv_id, message):
|
|
484
|
+
yield f"data: {json.dumps(event, default=str)}\n\n"
|
|
485
|
+
yield "data: [DONE]\n\n"
|
|
486
|
+
|
|
487
|
+
return Response(
|
|
488
|
+
generate(),
|
|
489
|
+
mimetype="text/event-stream",
|
|
490
|
+
headers={
|
|
491
|
+
"Cache-Control": "no-cache",
|
|
492
|
+
"X-Accel-Buffering": "no",
|
|
493
|
+
"Connection": "keep-alive",
|
|
494
|
+
},
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
@app.route("/api/chat/conversations", methods=["GET"])
|
|
499
|
+
def api_chat_conversations_list():
|
|
500
|
+
if not _chat_store:
|
|
501
|
+
return jsonify({"error": "not initialized"}), 500
|
|
502
|
+
limit = request.args.get("limit", 50, type=int)
|
|
503
|
+
return jsonify({"conversations": _chat_store.list_conversations(limit)})
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
@app.route("/api/chat/conversations", methods=["POST"])
|
|
507
|
+
def api_chat_conversations_create():
|
|
508
|
+
if not _chat_store:
|
|
509
|
+
return jsonify({"error": "not initialized"}), 500
|
|
510
|
+
data = request.get_json() or {}
|
|
511
|
+
title = data.get("title")
|
|
512
|
+
conv_id = _chat_store.create_conversation(title)
|
|
513
|
+
return jsonify({"id": conv_id}), 201
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
@app.route("/api/chat/conversations/<conv_id>", methods=["GET"])
|
|
517
|
+
def api_chat_conversation_get(conv_id):
|
|
518
|
+
if not _chat_store:
|
|
519
|
+
return jsonify({"error": "not initialized"}), 500
|
|
520
|
+
messages = _chat_store.get_conversation(conv_id)
|
|
521
|
+
return jsonify({"conversation_id": conv_id, "messages": messages})
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
@app.route("/api/chat/conversations/<conv_id>", methods=["DELETE"])
|
|
525
|
+
def api_chat_conversation_delete(conv_id):
|
|
526
|
+
if not _chat_store:
|
|
527
|
+
return jsonify({"error": "not initialized"}), 500
|
|
528
|
+
_chat_store.delete_conversation(conv_id)
|
|
529
|
+
return jsonify({"ok": True})
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
@app.route("/api/chat/conversations/<conv_id>/title", methods=["PUT"])
|
|
533
|
+
def api_chat_conversation_title(conv_id):
|
|
534
|
+
if not _chat_store:
|
|
535
|
+
return jsonify({"error": "not initialized"}), 500
|
|
536
|
+
data = request.get_json() or {}
|
|
537
|
+
title = data.get("title", "").strip()
|
|
538
|
+
if not title:
|
|
539
|
+
return jsonify({"error": "No title provided"}), 400
|
|
540
|
+
_chat_store.update_title(conv_id, title)
|
|
541
|
+
return jsonify({"ok": True})
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
@app.route("/api/chat/commands", methods=["GET"])
|
|
545
|
+
def api_chat_commands():
|
|
546
|
+
"""Return the slash command registry for frontend autocomplete."""
|
|
547
|
+
if not _chat_engine:
|
|
548
|
+
return jsonify({"error": "not initialized"}), 500
|
|
549
|
+
return jsonify({"commands": _chat_engine.get_commands()})
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
@app.route("/api/chat/command", methods=["POST"])
|
|
553
|
+
def api_chat_command():
|
|
554
|
+
"""Execute a slash command."""
|
|
555
|
+
if not _chat_engine:
|
|
556
|
+
return jsonify({"error": "not initialized"}), 500
|
|
557
|
+
data = request.get_json() or {}
|
|
558
|
+
conv_id = data.get("conversation_id")
|
|
559
|
+
command = data.get("command", "").strip()
|
|
560
|
+
if not command:
|
|
561
|
+
return jsonify({"error": "No command provided"}), 400
|
|
562
|
+
return jsonify(_chat_engine.execute_command(conv_id, command))
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
@app.route("/api/chat/conversations/<conv_id>/state", methods=["GET"])
|
|
566
|
+
def api_chat_conversation_state(conv_id):
|
|
567
|
+
"""Get conversation state (focused projects, model, depth)."""
|
|
568
|
+
if not _chat_store:
|
|
569
|
+
return jsonify({"error": "not initialized"}), 500
|
|
570
|
+
return jsonify({"state": _chat_store.get_state(conv_id)})
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
# ── Error handlers ────────────────────────────────────────
|
|
574
|
+
@app.errorhandler(404)
|
|
575
|
+
def not_found(e):
|
|
576
|
+
return jsonify({"error": "not found"}), 404
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
@app.errorhandler(405)
|
|
580
|
+
def method_not_allowed(e):
|
|
581
|
+
return jsonify({"error": "method not allowed"}), 405
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
# ── Startup ───────────────────────────────────────────────
|
|
585
|
+
def _port_free(port: int) -> bool:
|
|
586
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
587
|
+
return s.connect_ex(("127.0.0.1", port)) != 0
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _find_free_port(start: int, tries: int = 20) -> int:
|
|
591
|
+
for port in range(start, start + tries):
|
|
592
|
+
if _port_free(port):
|
|
593
|
+
return port
|
|
594
|
+
raise RuntimeError(f"No free port found near {start}")
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _is_oracle_running(port: int) -> bool:
|
|
598
|
+
try:
|
|
599
|
+
url = f"http://127.0.0.1:{port}/api/health"
|
|
600
|
+
with urllib.request.urlopen(url, timeout=1) as r:
|
|
601
|
+
data = json.loads(r.read())
|
|
602
|
+
return data.get("service") == "c3-oracle"
|
|
603
|
+
except Exception:
|
|
604
|
+
return False
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def run_oracle(port: int = None, open_browser: bool = None):
|
|
608
|
+
"""Main entry point for Oracle server."""
|
|
609
|
+
_init_services()
|
|
610
|
+
|
|
611
|
+
cfg = load_config()
|
|
612
|
+
dedicated_port = port if port is not None else cfg.get("port", 3331)
|
|
613
|
+
if open_browser is None:
|
|
614
|
+
open_browser = cfg.get("auto_open_browser", True)
|
|
615
|
+
|
|
616
|
+
# Single-instance check
|
|
617
|
+
if not _port_free(dedicated_port):
|
|
618
|
+
if _is_oracle_running(dedicated_port):
|
|
619
|
+
url = f"http://localhost:{dedicated_port}"
|
|
620
|
+
print(f"Oracle already running at {url}")
|
|
621
|
+
if open_browser:
|
|
622
|
+
webbrowser.open(url)
|
|
623
|
+
return
|
|
624
|
+
actual_port = _find_free_port(dedicated_port + 1)
|
|
625
|
+
print(f"Warning: port {dedicated_port} in use. Using {actual_port} instead.")
|
|
626
|
+
else:
|
|
627
|
+
actual_port = dedicated_port
|
|
628
|
+
|
|
629
|
+
# Set up logging
|
|
630
|
+
ORACLE_DIR.mkdir(parents=True, exist_ok=True)
|
|
631
|
+
log_level = getattr(logging, cfg.get("log_level", "INFO").upper(), logging.INFO)
|
|
632
|
+
logging.basicConfig(
|
|
633
|
+
level=log_level,
|
|
634
|
+
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
|
635
|
+
handlers=[
|
|
636
|
+
logging.FileHandler(ORACLE_DIR / "oracle.log", encoding="utf-8"),
|
|
637
|
+
logging.StreamHandler(),
|
|
638
|
+
],
|
|
639
|
+
)
|
|
640
|
+
logging.getLogger("werkzeug").setLevel(logging.WARNING)
|
|
641
|
+
|
|
642
|
+
url = f"http://localhost:{actual_port}"
|
|
643
|
+
print(f"Oracle Memory Agent → {url} (model: {cfg.get('model', 'gemma4:31b-cloud')})")
|
|
644
|
+
|
|
645
|
+
# Start review agent if enabled
|
|
646
|
+
if cfg.get("review_enabled", True) and _agent:
|
|
647
|
+
_agent.start()
|
|
648
|
+
atexit.register(_agent.stop)
|
|
649
|
+
|
|
650
|
+
if open_browser:
|
|
651
|
+
threading.Timer(0.8, lambda: webbrowser.open(url)).start()
|
|
652
|
+
|
|
653
|
+
app.run(host="0.0.0.0", port=actual_port, debug=False, use_reloader=False)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
if __name__ == "__main__":
|
|
657
|
+
import argparse
|
|
658
|
+
|
|
659
|
+
parser = argparse.ArgumentParser(description="Oracle Memory Agent for C3")
|
|
660
|
+
parser.add_argument("--port", type=int, default=None, help="Server port (default: 3331)")
|
|
661
|
+
parser.add_argument("--no-browser", action="store_true", help="Don't open browser")
|
|
662
|
+
args = parser.parse_args()
|
|
663
|
+
run_oracle(port=args.port, open_browser=not args.no_browser)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Oracle services."""
|