sari 0.0.1__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 (58) hide show
  1. app/__init__.py +1 -0
  2. app/config.py +240 -0
  3. app/db.py +932 -0
  4. app/dedup_queue.py +77 -0
  5. app/engine_registry.py +56 -0
  6. app/engine_runtime.py +472 -0
  7. app/http_server.py +204 -0
  8. app/indexer.py +1532 -0
  9. app/main.py +147 -0
  10. app/models.py +39 -0
  11. app/queue_pipeline.py +65 -0
  12. app/ranking.py +144 -0
  13. app/registry.py +172 -0
  14. app/search_engine.py +572 -0
  15. app/watcher.py +124 -0
  16. app/workspace.py +286 -0
  17. deckard/__init__.py +3 -0
  18. deckard/__main__.py +4 -0
  19. deckard/main.py +345 -0
  20. deckard/version.py +1 -0
  21. mcp/__init__.py +1 -0
  22. mcp/__main__.py +19 -0
  23. mcp/cli.py +485 -0
  24. mcp/daemon.py +149 -0
  25. mcp/proxy.py +304 -0
  26. mcp/registry.py +218 -0
  27. mcp/server.py +519 -0
  28. mcp/session.py +234 -0
  29. mcp/telemetry.py +112 -0
  30. mcp/test_cli.py +89 -0
  31. mcp/test_daemon.py +124 -0
  32. mcp/test_server.py +197 -0
  33. mcp/tools/__init__.py +14 -0
  34. mcp/tools/_util.py +244 -0
  35. mcp/tools/deckard_guide.py +32 -0
  36. mcp/tools/doctor.py +208 -0
  37. mcp/tools/get_callers.py +60 -0
  38. mcp/tools/get_implementations.py +60 -0
  39. mcp/tools/index_file.py +75 -0
  40. mcp/tools/list_files.py +138 -0
  41. mcp/tools/read_file.py +48 -0
  42. mcp/tools/read_symbol.py +99 -0
  43. mcp/tools/registry.py +212 -0
  44. mcp/tools/repo_candidates.py +89 -0
  45. mcp/tools/rescan.py +46 -0
  46. mcp/tools/scan_once.py +54 -0
  47. mcp/tools/search.py +208 -0
  48. mcp/tools/search_api_endpoints.py +72 -0
  49. mcp/tools/search_symbols.py +63 -0
  50. mcp/tools/status.py +135 -0
  51. sari/__init__.py +1 -0
  52. sari/__main__.py +4 -0
  53. sari-0.0.1.dist-info/METADATA +521 -0
  54. sari-0.0.1.dist-info/RECORD +58 -0
  55. sari-0.0.1.dist-info/WHEEL +5 -0
  56. sari-0.0.1.dist-info/entry_points.txt +2 -0
  57. sari-0.0.1.dist-info/licenses/LICENSE +21 -0
  58. sari-0.0.1.dist-info/top_level.txt +4 -0
app/http_server.py ADDED
@@ -0,0 +1,204 @@
1
+ import json
2
+ import threading
3
+ from http.server import BaseHTTPRequestHandler, HTTPServer
4
+ from urllib.parse import parse_qs, urlparse
5
+
6
+ # Support script mode and package mode
7
+ try:
8
+ from .db import LocalSearchDB # type: ignore
9
+ from .indexer import Indexer # type: ignore
10
+ from .models import SearchOptions # type: ignore
11
+ except ImportError:
12
+ from db import LocalSearchDB # type: ignore
13
+ from indexer import Indexer # type: ignore
14
+ from models import SearchOptions # type: ignore
15
+
16
+
17
+ class Handler(BaseHTTPRequestHandler):
18
+ # class attributes injected in `serve_forever`
19
+ db: LocalSearchDB
20
+ indexer: Indexer
21
+ server_host: str = "127.0.0.1"
22
+ server_port: int = 47777
23
+ server_version: str = "dev"
24
+ root_ids: list[str] = []
25
+
26
+ def _json(self, obj, status=200):
27
+ body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
28
+ self.send_response(status)
29
+ self.send_header("Content-Type", "application/json; charset=utf-8")
30
+ self.send_header("Content-Length", str(len(body)))
31
+ self.end_headers()
32
+ self.wfile.write(body)
33
+
34
+ def log_message(self, format, *args):
35
+ # keep logs quiet
36
+ return
37
+
38
+ def do_GET(self):
39
+ parsed = urlparse(self.path)
40
+ path = parsed.path
41
+ qs = parse_qs(parsed.query)
42
+
43
+ if path == "/health":
44
+ return self._json({"ok": True})
45
+
46
+ if path == "/status":
47
+ st = self.indexer.status
48
+ return self._json(
49
+ {
50
+ "ok": True,
51
+ "host": self.server_host,
52
+ "port": self.server_port,
53
+ "version": self.server_version,
54
+ "index_ready": bool(st.index_ready),
55
+ "last_scan_ts": st.last_scan_ts,
56
+ "last_commit_ts": self.indexer.get_last_commit_ts() if hasattr(self.indexer, "get_last_commit_ts") else 0,
57
+ "scanned_files": st.scanned_files,
58
+ "indexed_files": st.indexed_files,
59
+ "errors": st.errors,
60
+ "fts_enabled": self.db.fts_enabled,
61
+ "queue_depths": self.indexer.get_queue_depths() if hasattr(self.indexer, "get_queue_depths") else {},
62
+ }
63
+ )
64
+
65
+ if path == "/search":
66
+ q = (qs.get("q") or [""])[0].strip()
67
+ repo = (qs.get("repo") or [""])[0].strip() or None
68
+ limit = int((qs.get("limit") or ["20"])[0])
69
+ total_mode = (qs.get("total_mode") or [""])[0].strip().lower()
70
+ root_ids = qs.get("root_ids") or []
71
+ if not q:
72
+ return self._json({"ok": False, "error": "missing q"}, status=400)
73
+ engine = getattr(self.db, "engine", None)
74
+ engine_mode = "sqlite"
75
+ index_version = ""
76
+ if engine and hasattr(engine, "status"):
77
+ st = engine.status()
78
+ engine_mode = st.engine_mode
79
+ index_version = st.index_version
80
+ if engine_mode == "embedded" and not st.engine_ready:
81
+ return self._json({"ok": False, "error": f"engine_ready=false reason={st.reason}", "hint": st.hint}, status=503)
82
+ req_root_ids: list[str] = []
83
+ for item in root_ids:
84
+ if "," in item:
85
+ req_root_ids.extend([r for r in item.split(",") if r])
86
+ elif item:
87
+ req_root_ids.append(item)
88
+ allowed = list(self.root_ids or [])
89
+ final_root_ids = allowed
90
+ if req_root_ids:
91
+ final_root_ids = [r for r in allowed if r in req_root_ids]
92
+ if req_root_ids and not final_root_ids:
93
+ if self.db.has_legacy_paths():
94
+ final_root_ids = []
95
+ else:
96
+ return self._json({"ok": False, "error": "root_ids out of scope"}, status=400)
97
+ snippet_lines = max(1, min(int(self.indexer.cfg.snippet_max_lines), 20))
98
+ opts = SearchOptions(
99
+ query=q,
100
+ repo=repo,
101
+ limit=max(1, min(limit, 50)),
102
+ snippet_lines=snippet_lines,
103
+ root_ids=final_root_ids,
104
+ total_mode=total_mode if total_mode in {"exact", "approx"} else "exact",
105
+ )
106
+ try:
107
+ hits, meta = self.db.search_v2(opts)
108
+ except Exception as e:
109
+ return self._json({"ok": False, "error": f"engine query failed: {e}"}, status=500)
110
+ return self._json(
111
+ {"ok": True, "q": q, "repo": repo, "meta": meta, "engine": engine_mode, "index_version": index_version, "hits": [h.__dict__ for h in hits]}
112
+ )
113
+
114
+ if path == "/repo-candidates":
115
+ q = (qs.get("q") or [""])[0].strip()
116
+ limit = int((qs.get("limit") or ["3"])[0])
117
+ if not q:
118
+ return self._json({"ok": False, "error": "missing q"}, status=400)
119
+ cands = self.db.repo_candidates(q=q, limit=max(1, min(limit, 5)), root_ids=self.root_ids)
120
+ return self._json({"ok": True, "q": q, "candidates": cands})
121
+
122
+ if path == "/rescan":
123
+ # Trigger a scan ASAP (non-blocking)
124
+ self.indexer.request_rescan()
125
+ return self._json({"ok": True, "requested": True})
126
+
127
+ return self._json({"ok": False, "error": "not found"}, status=404)
128
+
129
+
130
+ def serve_forever(host: str, port: int, db: LocalSearchDB, indexer: Indexer, version: str = "dev", workspace_root: str = "") -> tuple:
131
+ """Start HTTP server with Registry-based port allocation (v2.7.0).
132
+
133
+ Returns:
134
+ tuple: (HTTPServer, actual_port)
135
+ """
136
+ import socket
137
+ import sys
138
+ import os
139
+
140
+ # Try importing registry, fallback if missing
141
+ try:
142
+ from .registry import ServerRegistry # type: ignore
143
+ registry = ServerRegistry()
144
+ has_registry = True
145
+ except ImportError:
146
+ registry = None
147
+ has_registry = False
148
+
149
+ # Bind dependencies as class attributes
150
+ class BoundHandler(Handler):
151
+ pass
152
+
153
+ BoundHandler.db = db # type: ignore
154
+ BoundHandler.indexer = indexer # type: ignore
155
+ BoundHandler.server_host = host # type: ignore
156
+ BoundHandler.server_version = version # type: ignore
157
+ try:
158
+ from app.workspace import WorkspaceManager
159
+ BoundHandler.root_ids = [WorkspaceManager.root_id(r) for r in indexer.cfg.workspace_roots] # type: ignore
160
+ except Exception:
161
+ BoundHandler.root_ids = [] # type: ignore
162
+
163
+ strategy = (os.environ.get("DECKARD_HTTP_API_PORT_STRATEGY") or "auto").strip().lower()
164
+ actual_port = port
165
+ httpd = None
166
+ try:
167
+ BoundHandler.server_port = actual_port # type: ignore
168
+ httpd = HTTPServer((host, actual_port), BoundHandler)
169
+ except OSError as e:
170
+ if strategy == "strict":
171
+ raise RuntimeError(f"HTTP API port {actual_port} unavailable: {e}")
172
+ # auto strategy: retry with port=0 (OS-assigned)
173
+ try:
174
+ BoundHandler.server_port = 0 # type: ignore
175
+ httpd = HTTPServer((host, 0), BoundHandler)
176
+ actual_port = httpd.server_address[1]
177
+ except OSError:
178
+ raise RuntimeError("Failed to create HTTP server")
179
+
180
+ if httpd is None:
181
+ raise RuntimeError("Failed to create HTTP server")
182
+
183
+ actual_port = httpd.server_address[1]
184
+ BoundHandler.server_port = actual_port # type: ignore
185
+
186
+ # Register in server.json
187
+ if has_registry and workspace_root:
188
+ try:
189
+ registry.register(workspace_root, actual_port, os.getpid())
190
+ except Exception as e:
191
+ print(f"[sari] Registry update failed: {e}", file=sys.stderr)
192
+
193
+ if actual_port != port:
194
+ print(f"[sari] HTTP API started on port {actual_port} (requested: {port})", file=sys.stderr)
195
+
196
+ # Clean shutdown hook?
197
+ # HTTP Server runs in thread, so unregistering is tricky if main thread dies hard.
198
+ # But serve_forever is called in thread usually.
199
+ # The caller (mcp.server) is responsible for unregistering OR we trust 'pid' check.
200
+ # Let's rely on PID check for now (lazy cleanup), but try to unregister if possible.
201
+
202
+ th = threading.Thread(target=httpd.serve_forever, daemon=True)
203
+ th.start()
204
+ return (httpd, actual_port)