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