oxe 0.1.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.
oxe/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """oxe: Exa-compatible web-search proxy and MCP server backed by DuckDuckGo."""
2
+
3
+ __version__ = "0.1.0"
oxe/__main__.py ADDED
@@ -0,0 +1,19 @@
1
+ import os
2
+
3
+ import uvicorn
4
+
5
+ from .server import app
6
+
7
+
8
+ def main() -> None:
9
+ uvicorn.run(
10
+ app,
11
+ host="127.0.0.1",
12
+ port=int(os.getenv("OXE_PORT", "4479")),
13
+ workers=1,
14
+ log_level=os.getenv("OXE_LOG_LEVEL", "info").lower(),
15
+ )
16
+
17
+
18
+ if __name__ == "__main__":
19
+ main()
oxe/cache.py ADDED
@@ -0,0 +1,245 @@
1
+ import gzip
2
+ import json
3
+ import os
4
+ import sqlite3
5
+ import threading
6
+ import time
7
+ from pathlib import Path
8
+
9
+ SCHEMA = """
10
+ PRAGMA journal_mode = WAL;
11
+ PRAGMA synchronous = NORMAL;
12
+ PRAGMA cache_size = -32000;
13
+ PRAGMA temp_store = MEMORY;
14
+ PRAGMA busy_timeout = 5000;
15
+
16
+ CREATE TABLE IF NOT EXISTS cache (
17
+ query_hash TEXT PRIMARY KEY,
18
+ query_text TEXT NOT NULL,
19
+ response BLOB NOT NULL,
20
+ expires_at INTEGER NOT NULL,
21
+ hits INTEGER NOT NULL DEFAULT 0
22
+ ) WITHOUT ROWID;
23
+ CREATE INDEX IF NOT EXISTS expires_idx ON cache(expires_at);
24
+
25
+ CREATE TABLE IF NOT EXISTS clicks (
26
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
27
+ query_hash TEXT NOT NULL,
28
+ result_id TEXT NOT NULL,
29
+ url TEXT NOT NULL,
30
+ title TEXT NOT NULL,
31
+ clicked_at INTEGER NOT NULL,
32
+ source TEXT NOT NULL DEFAULT 'web'
33
+ );
34
+ CREATE INDEX IF NOT EXISTS clicks_query_idx ON clicks(query_hash);
35
+ CREATE INDEX IF NOT EXISTS clicks_recent_idx ON clicks(clicked_at);
36
+ """
37
+
38
+
39
+ class TTLCache:
40
+ def __init__(self, db_path: str | os.PathLike[str]):
41
+ self.db_path = str(db_path)
42
+ Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
43
+ self._lock = threading.Lock()
44
+ self._conn = sqlite3.connect(self.db_path, check_same_thread=False, isolation_level=None)
45
+ self._conn.executescript(SCHEMA)
46
+ self._conn.execute("PRAGMA optimize;")
47
+
48
+ def get(self, key: str) -> dict | None:
49
+ now = int(time.time())
50
+ with self._lock:
51
+ row = self._conn.execute(
52
+ "SELECT response, expires_at FROM cache WHERE query_hash = ?", (key,)
53
+ ).fetchone()
54
+ if row is None:
55
+ return None
56
+ blob, expires_at = row
57
+ if expires_at < now:
58
+ return None
59
+ try:
60
+ payload = gzip.decompress(blob)
61
+ except (OSError, gzip.BadGzipFile):
62
+ return None
63
+ with self._lock:
64
+ self._conn.execute(
65
+ "UPDATE cache SET hits = hits + 1 WHERE query_hash = ?", (key,)
66
+ )
67
+ return json.loads(payload)
68
+
69
+ def set(self, key: str, value: dict, ttl: int) -> None:
70
+ payload = gzip.compress(json.dumps(value, separators=(",", ":")).encode("utf-8"))
71
+ expires_at = int(time.time()) + ttl
72
+ with self._lock:
73
+ self._conn.execute(
74
+ "INSERT OR REPLACE INTO cache (query_hash, query_text, response, expires_at, hits) "
75
+ "VALUES (?, ?, ?, ?, COALESCE((SELECT hits FROM cache WHERE query_hash = ?), 0))",
76
+ (key, value.get("_q", ""), payload, expires_at, key),
77
+ )
78
+ self._conn.execute("DELETE FROM cache WHERE expires_at < ?", (expires_at - ttl - 1,))
79
+
80
+ def invalidate(self) -> int:
81
+ with self._lock:
82
+ cur = self._conn.execute("DELETE FROM cache")
83
+ return cur.rowcount
84
+
85
+ def list_rows(
86
+ self,
87
+ q: str | None = None,
88
+ include_expired: bool = False,
89
+ limit: int = 100,
90
+ offset: int = 0,
91
+ ) -> list[dict]:
92
+ now = int(time.time())
93
+ clauses: list[str] = []
94
+ params: list = []
95
+ if not include_expired:
96
+ clauses.append("expires_at >= ?")
97
+ params.append(now)
98
+ if q:
99
+ clauses.append("query_text LIKE ?")
100
+ params.append(f"%{q}%")
101
+ where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
102
+ sql = (
103
+ "SELECT query_hash, query_text, expires_at, hits, length(response) "
104
+ f"FROM cache{where} ORDER BY expires_at DESC LIMIT ? OFFSET ?"
105
+ )
106
+ params.extend([limit, offset])
107
+ with self._lock:
108
+ rows = self._conn.execute(sql, params).fetchall()
109
+ return [
110
+ {
111
+ "hash": h,
112
+ "query": qtext,
113
+ "expires_at": exp,
114
+ "hits": hits,
115
+ "size_bytes": size,
116
+ "expired": exp < now,
117
+ }
118
+ for h, qtext, exp, hits, size in rows
119
+ ]
120
+
121
+ def peek(self, key: str) -> dict | None:
122
+ return self.get(key)
123
+
124
+ def delete(self, key: str) -> bool:
125
+ with self._lock:
126
+ cur = self._conn.execute("DELETE FROM cache WHERE query_hash = ?", (key,))
127
+ return cur.rowcount > 0
128
+
129
+ def stats(self) -> dict:
130
+ now = int(time.time())
131
+ with self._lock:
132
+ total = self._conn.execute("SELECT COUNT(*) FROM cache").fetchone()[0]
133
+ unexpired = self._conn.execute(
134
+ "SELECT COUNT(*) FROM cache WHERE expires_at >= ?", (now,)
135
+ ).fetchone()[0]
136
+ total_hits = self._conn.execute("SELECT COALESCE(SUM(hits), 0) FROM cache").fetchone()[0]
137
+ oldest = self._conn.execute(
138
+ "SELECT MIN(expires_at) FROM cache WHERE expires_at >= ?", (now,)
139
+ ).fetchone()[0]
140
+ newest_row = self._conn.execute(
141
+ "SELECT MAX(expires_at) FROM cache"
142
+ ).fetchone()[0]
143
+ db_size = os.path.getsize(self.db_path) if os.path.exists(self.db_path) else 0
144
+ return {
145
+ "rows": total,
146
+ "unexpired_rows": unexpired,
147
+ "db_size_bytes": db_size,
148
+ "total_hits": total_hits,
149
+ "oldest_unexpired": oldest,
150
+ "newest": newest_row,
151
+ }
152
+
153
+ def close(self) -> None:
154
+ with self._lock:
155
+ self._conn.close()
156
+
157
+ # -- click tracking -----------------------------------------------------
158
+
159
+ def record_click(
160
+ self,
161
+ query_hash: str,
162
+ result_id: str,
163
+ url: str,
164
+ title: str,
165
+ source: str = "web",
166
+ ) -> int:
167
+ now = int(time.time())
168
+ with self._lock:
169
+ cur = self._conn.execute(
170
+ "INSERT INTO clicks (query_hash, result_id, url, title, clicked_at, source) "
171
+ "VALUES (?, ?, ?, ?, ?, ?)",
172
+ (query_hash, result_id, url, title, now, source),
173
+ )
174
+ return cur.lastrowid or 0
175
+
176
+ def get_clicks(
177
+ self,
178
+ query_hash: str | None = None,
179
+ query_text: str | None = None,
180
+ limit: int = 50,
181
+ since_hours: int | None = None,
182
+ ) -> list[dict]:
183
+ clauses: list[str] = []
184
+ params: list = []
185
+ join = " LEFT JOIN cache k ON k.query_hash = c.query_hash "
186
+ if query_hash:
187
+ clauses.append("c.query_hash = ?")
188
+ params.append(query_hash)
189
+ if query_text:
190
+ clauses.append("k.query_text LIKE ?")
191
+ params.append(f"%{query_text}%")
192
+ if since_hours is not None:
193
+ clauses.append("c.clicked_at >= ?")
194
+ params.append(int(time.time()) - since_hours * 3600)
195
+ where = (" WHERE " + " AND ".join(clauses)) if clauses else ""
196
+ sql = (
197
+ "SELECT c.id, c.query_hash, COALESCE(k.query_text, ''), c.result_id, c.url, "
198
+ "c.title, c.clicked_at, c.source "
199
+ f"FROM clicks c{join}{where} ORDER BY c.clicked_at DESC LIMIT ?"
200
+ )
201
+ params.append(limit)
202
+ with self._lock:
203
+ rows = self._conn.execute(sql, params).fetchall()
204
+ return [
205
+ {
206
+ "id": cid,
207
+ "query_hash": qh,
208
+ "query": qt,
209
+ "result_id": rid,
210
+ "url": url,
211
+ "title": title,
212
+ "clicked_at": ts,
213
+ "source": src,
214
+ }
215
+ for cid, qh, qt, rid, url, title, ts, src in rows
216
+ ]
217
+
218
+ def click_stats(self) -> dict:
219
+ now = int(time.time())
220
+ with self._lock:
221
+ total = self._conn.execute("SELECT COUNT(*) FROM clicks").fetchone()[0]
222
+ last_24h = self._conn.execute(
223
+ "SELECT COUNT(*) FROM clicks WHERE clicked_at >= ?", (now - 86400,)
224
+ ).fetchone()[0]
225
+ oldest = self._conn.execute("SELECT MIN(clicked_at) FROM clicks").fetchone()[0]
226
+ return {"total": total, "last_24h": last_24h, "oldest": oldest}
227
+
228
+ def prune_clicks(self, retention_days: int) -> int:
229
+ cutoff = int(time.time()) - retention_days * 86400
230
+ with self._lock:
231
+ cur = self._conn.execute("DELETE FROM clicks WHERE clicked_at < ?", (cutoff,))
232
+ return cur.rowcount
233
+
234
+ def delete_clicks(self, scope: str) -> int:
235
+ """scope: '24h' deletes last 24h, 'all' deletes everything."""
236
+ with self._lock:
237
+ if scope == "all":
238
+ cur = self._conn.execute("DELETE FROM clicks")
239
+ elif scope == "24h":
240
+ cur = self._conn.execute(
241
+ "DELETE FROM clicks WHERE clicked_at >= ?", (int(time.time()) - 86400,)
242
+ )
243
+ else:
244
+ return 0
245
+ return cur.rowcount
oxe/exa_compat.py ADDED
@@ -0,0 +1,131 @@
1
+ import hashlib
2
+ import logging
3
+ import re
4
+ import uuid
5
+ from typing import Any
6
+ from urllib.parse import urlparse
7
+
8
+ from ddgs import DDGS
9
+
10
+ log = logging.getLogger(__name__)
11
+
12
+ _SENTENCE_SPLIT = re.compile(r"(?<=[.!?])\s+")
13
+ _NUM_CLAMP = (1, 30)
14
+ _FAVICON = "https://www.google.com/s2/favicons?domain={netloc}&sz=32"
15
+
16
+
17
+ def cache_key(req: dict) -> str:
18
+ contents = req.get("contents") or {}
19
+ norm = (
20
+ (req.get("query") or "").lower().strip(),
21
+ max(_NUM_CLAMP[0], min(_NUM_CLAMP[1], int(req.get("numResults") or 10))),
22
+ req.get("type") or "auto",
23
+ tuple(sorted(req.get("includeDomains") or [])),
24
+ tuple(sorted(req.get("excludeDomains") or [])),
25
+ bool(contents.get("highlights")),
26
+ bool(contents.get("text")),
27
+ )
28
+ return hashlib.sha256(repr(norm).encode("utf-8")).hexdigest()
29
+
30
+
31
+ def build_query(req: dict) -> str:
32
+ parts: list[str] = [(req.get("query") or "").strip()]
33
+ for d in req.get("includeDomains") or []:
34
+ if d:
35
+ parts.append(f"site:{d}")
36
+ for d in req.get("excludeDomains") or []:
37
+ if d:
38
+ parts.append(f"-site:{d}")
39
+ return " ".join(p for p in parts if p)
40
+
41
+
42
+ def _extract_highlights(body: str | None, max_n: int = 3) -> list[str]:
43
+ if not body:
44
+ return []
45
+ sentences = [s.strip() for s in _SENTENCE_SPLIT.split(body) if s.strip()]
46
+ return sentences[:max_n]
47
+
48
+
49
+ def _favicon_for(url: str) -> str:
50
+ try:
51
+ netloc = urlparse(url).netloc
52
+ except ValueError:
53
+ netloc = ""
54
+ return _FAVICON.format(netloc=netloc)
55
+
56
+
57
+ def _dgr_to_exa(r: dict, contents_highlights: bool, contents_text: bool) -> dict:
58
+ href = r.get("href") or ""
59
+ body = r.get("body") or ""
60
+ text = body if contents_text else ""
61
+ highlights = _extract_highlights(body) if contents_highlights else []
62
+ return {
63
+ "title": r.get("title") or "",
64
+ "url": href,
65
+ "id": href,
66
+ "text": text,
67
+ "highlights": highlights,
68
+ "highlightScores": [0.5] * len(highlights),
69
+ "publishedDate": None,
70
+ "author": None,
71
+ "image": None,
72
+ "favicon": _favicon_for(href),
73
+ "extras": {"links": []},
74
+ }
75
+
76
+
77
+ def search(req: dict) -> dict:
78
+ ignored = []
79
+ for field in ("startPublishedDate", "endPublishedDate", "additionalQueries",
80
+ "systemPrompt", "outputSchema", "stream"):
81
+ if req.get(field):
82
+ ignored.append(field)
83
+ if (req.get("contents") or {}).get("summary"):
84
+ ignored.append("contents.summary")
85
+ if ignored:
86
+ log.warning("exa_compat: ignoring unsupported fields: %s", ignored)
87
+
88
+ contents = req.get("contents") or {}
89
+ contents_highlights = bool(contents.get("highlights"))
90
+ contents_text = bool(contents.get("text"))
91
+
92
+ num_results = max(_NUM_CLAMP[0], min(_NUM_CLAMP[1], int(req.get("numResults") or 10)))
93
+ query = build_query(req)
94
+ search_type = req.get("type") or "auto"
95
+
96
+ region = "wt-wt" if search_type == "instant" else None
97
+ timelimit = "d" if req.get("category") == "news" else None
98
+
99
+ backends_to_try = ["duckduckgo", "auto"]
100
+ raw: list[dict[str, Any]] = []
101
+ last_err: Exception | None = None
102
+ for backend in backends_to_try:
103
+ try:
104
+ kwargs: dict[str, Any] = dict(
105
+ query=query,
106
+ max_results=num_results,
107
+ backend=backend,
108
+ safesearch="moderate",
109
+ )
110
+ if region:
111
+ kwargs["region"] = region
112
+ if timelimit:
113
+ kwargs["timelimit"] = timelimit
114
+ raw = list(DDGS().text(**kwargs))
115
+ if raw:
116
+ break
117
+ except Exception as e:
118
+ last_err = e
119
+ log.warning("exa_compat: backend %s failed: %s", backend, e)
120
+ continue
121
+
122
+ if not raw and last_err is not None:
123
+ log.error("exa_compat: all backends failed, last error: %s", last_err)
124
+
125
+ results = [_dgr_to_exa(r, contents_highlights, contents_text) for r in raw]
126
+ return {
127
+ "requestId": str(uuid.uuid4()),
128
+ "searchType": search_type,
129
+ "results": results,
130
+ "costDollars": {"total": 0.0},
131
+ }
oxe/mcp_server.py ADDED
@@ -0,0 +1,84 @@
1
+ from typing import Any
2
+
3
+ from mcp.server.mcpserver import MCPServer
4
+
5
+ from . import exa_compat
6
+ from .cache import TTLCache
7
+
8
+ _cache: TTLCache | None = None
9
+
10
+
11
+ def set_cache(c: TTLCache) -> None:
12
+ global _cache
13
+ _cache = c
14
+
15
+
16
+ mcp = MCPServer(
17
+ name="oxe",
18
+ instructions=(
19
+ "Local Exa-compatible web search backed by DuckDuckGo with a TTL cache. "
20
+ "Query returns Exa-shaped JSON: {requestId, searchType, results, costDollars} "
21
+ "where each result has {title, url, id, text, highlights, highlightScores, "
22
+ "publishedDate, author, image, favicon, extras}. Unimplemented Exa fields "
23
+ "(deep search variants, contents.summary, additionalQueries, systemPrompt, "
24
+ "outputSchema, stream) are silently ignored."
25
+ ),
26
+ )
27
+
28
+
29
+ @mcp.tool(name="exa_search", description=(
30
+ "Search the web via DuckDuckGo and return Exa-shaped JSON. "
31
+ "Args: query (required), num_results (1-30, default 10), type ('auto'|'instant'; "
32
+ "deep variants ignored), contents_highlights, contents_text, include_domains, "
33
+ "exclude_domains, category ('news' for last 24h, else ''). "
34
+ "Returns {requestId, searchType, results, costDollars, _source}."
35
+ ))
36
+ def exa_search(
37
+ query: str,
38
+ num_results: int = 10,
39
+ type: str = "auto",
40
+ contents_highlights: bool = True,
41
+ contents_text: bool = True,
42
+ include_domains: list[str] | None = None,
43
+ exclude_domains: list[str] | None = None,
44
+ category: str = "",
45
+ ) -> dict[str, Any]:
46
+ req: dict[str, Any] = {
47
+ "query": query,
48
+ "numResults": num_results,
49
+ "type": type,
50
+ "contents": {"highlights": contents_highlights, "text": contents_text},
51
+ "category": category,
52
+ }
53
+ if include_domains:
54
+ req["includeDomains"] = include_domains
55
+ if exclude_domains:
56
+ req["excludeDomains"] = exclude_domains
57
+ if _cache is None:
58
+ return exa_compat.search(req)
59
+ from .search import do_search
60
+ out = do_search(_cache, req)
61
+ return out
62
+
63
+
64
+ @mcp.tool(name="exa_user_history", description=(
65
+ "Recent URLs the user has clicked from the search UI for a given query. "
66
+ "Use this to avoid re-researching what the user has already explored. "
67
+ "Args: query (optional substring match against query text), query_hash "
68
+ "(optional exact match), limit (1-200, default 20), since_hours (default 168 = 1 week). "
69
+ "Returns {clicks: [{query_hash, query, result_id, url, title, clicked_at, source}], count}."
70
+ ))
71
+ def exa_user_history(
72
+ query: str = "",
73
+ query_hash: str = "",
74
+ limit: int = 20,
75
+ since_hours: int = 168,
76
+ ) -> dict[str, Any]:
77
+ if _cache is None:
78
+ return {"clicks": [], "count": 0, "error": "cache not initialized"}
79
+ qh = query_hash.strip() or None
80
+ qt = query.strip() or None
81
+ since = max(1, min(since_hours, 24 * 365))
82
+ lim = max(1, min(limit, 200))
83
+ rows = _cache.get_clicks(query_hash=qh, query_text=qt, limit=lim, since_hours=since)
84
+ return {"clicks": rows, "count": len(rows)}
oxe/search.py ADDED
@@ -0,0 +1,33 @@
1
+ import logging
2
+ import os
3
+ from typing import Optional
4
+
5
+ from . import exa_compat
6
+ from .cache import TTLCache
7
+
8
+ log = logging.getLogger(__name__)
9
+
10
+ TTL_DEFAULT = int(os.getenv("OXE_TTL_DEFAULT", "3600"))
11
+ TTL_MAX = int(os.getenv("OXE_TTL_MAX", "86400"))
12
+ NEGATIVE_TTL = int(os.getenv("OXE_NEGATIVE_TTL", "300"))
13
+
14
+
15
+ def do_search(cache: TTLCache, req_dict: dict, ttl: Optional[int] = None) -> dict:
16
+ key = exa_compat.cache_key(req_dict)
17
+ cached = cache.get(key)
18
+ if cached is not None:
19
+ out = dict(cached)
20
+ out["_source"] = "cache"
21
+ out["_q_hash"] = key
22
+ return out
23
+
24
+ response = exa_compat.search(req_dict)
25
+ effective_ttl = ttl if ttl is not None else (NEGATIVE_TTL if not response["results"] else TTL_DEFAULT)
26
+ effective_ttl = min(effective_ttl, TTL_MAX)
27
+ to_store = dict(response)
28
+ to_store["_q"] = (req_dict.get("query") or "")[:200]
29
+ cache.set(key, to_store, effective_ttl)
30
+ out = dict(response)
31
+ out["_source"] = "network"
32
+ out["_q_hash"] = key
33
+ return out