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
mcp/cli.py ADDED
@@ -0,0 +1,485 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Sari CLI - Command-line interface for daemon management.
4
+
5
+ Usage:
6
+ sari daemon start [-d] Start daemon (foreground or daemonized)
7
+ sari daemon stop Stop running daemon
8
+ sari daemon status Check daemon status
9
+ sari proxy Run in proxy mode (stdio ↔ daemon)
10
+ """
11
+ import argparse
12
+ import json
13
+ import os
14
+ import signal
15
+ import socket
16
+ import subprocess
17
+ import sys
18
+ import time
19
+ import urllib.parse
20
+ import urllib.request
21
+ import ipaddress
22
+ import sys
23
+ from pathlib import Path
24
+ from typing import Optional, Dict, Any, Tuple
25
+
26
+ # Add project root to sys.path for absolute imports
27
+ SCRIPT_DIR = Path(__file__).parent
28
+ REPO_ROOT = SCRIPT_DIR.parent
29
+ if str(REPO_ROOT) not in sys.path:
30
+ sys.path.insert(0, str(REPO_ROOT))
31
+
32
+
33
+ from app.workspace import WorkspaceManager
34
+ from app.registry import ServerRegistry
35
+
36
+
37
+ DEFAULT_HOST = "127.0.0.1"
38
+ DEFAULT_PORT = 47779
39
+ PID_FILE = WorkspaceManager.get_global_data_dir() / "daemon.pid"
40
+ DEFAULT_HTTP_HOST = "127.0.0.1"
41
+ DEFAULT_HTTP_PORT = 47777
42
+
43
+
44
+ def get_daemon_address():
45
+ """Get daemon host and port from environment or defaults."""
46
+ host = os.environ.get("DECKARD_DAEMON_HOST", DEFAULT_HOST)
47
+ port = int(os.environ.get("DECKARD_DAEMON_PORT", DEFAULT_PORT))
48
+ return host, port
49
+
50
+
51
+ def _package_config_path() -> Path:
52
+ return Path(__file__).parent.parent / "config" / "config.json"
53
+
54
+
55
+ def _load_http_config(workspace_root: str) -> Optional[dict]:
56
+ try:
57
+ from app.workspace import WorkspaceManager
58
+ from app.config import Config
59
+ cfg_path = WorkspaceManager.resolve_config_path(workspace_root)
60
+ return json.loads(Path(cfg_path).read_text(encoding="utf-8")) if Path(cfg_path).exists() else None
61
+ except Exception:
62
+ return None
63
+
64
+ def _load_server_info(workspace_root: str) -> Optional[dict]:
65
+ """Legacy server.json location for backward compatibility."""
66
+ server_json = Path(workspace_root) / ".codex" / "tools" / "sari" / "data" / "server.json"
67
+ if not server_json.exists():
68
+ return None
69
+ try:
70
+ return json.loads(server_json.read_text(encoding="utf-8"))
71
+ except Exception:
72
+ return None
73
+
74
+
75
+ def _is_loopback(host: str) -> bool:
76
+ h = (host or "").strip().lower()
77
+ if h == "localhost":
78
+ return True
79
+ try:
80
+ return ipaddress.ip_address(h).is_loopback
81
+ except ValueError:
82
+ return False
83
+
84
+
85
+ def _enforce_loopback(host: str) -> None:
86
+ if os.environ.get("DECKARD_ALLOW_NON_LOOPBACK") == "1" or os.environ.get("LOCAL_SEARCH_ALLOW_NON_LOOPBACK") == "1":
87
+ return
88
+ if not _is_loopback(host):
89
+ raise RuntimeError(
90
+ f"sari loopback-only: server_host must be 127.0.0.1/localhost/::1 (got={host}). "
91
+ "Set DECKARD_ALLOW_NON_LOOPBACK=1 to override (NOT recommended)."
92
+ )
93
+
94
+
95
+ def _get_http_host_port() -> tuple[str, int]:
96
+ """Get active HTTP server address with Environment priority (v2.7.0)."""
97
+ # 0. Environment Override (Highest Priority for testing/isolation)
98
+ env_host = (
99
+ os.environ.get("DECKARD_HTTP_API_HOST")
100
+ or os.environ.get("DECKARD_HTTP_HOST")
101
+ or os.environ.get("LOCAL_SEARCH_HTTP_HOST")
102
+ or os.environ.get("DECKARD_HOST")
103
+ )
104
+ env_port_raw = (
105
+ os.environ.get("DECKARD_HTTP_API_PORT")
106
+ or os.environ.get("DECKARD_HTTP_PORT")
107
+ or os.environ.get("LOCAL_SEARCH_HTTP_PORT")
108
+ or os.environ.get("DECKARD_PORT")
109
+ )
110
+ env_port = None if env_port_raw in (None, "", "0") else env_port_raw
111
+ if env_host or env_port:
112
+ return str(env_host or DEFAULT_HTTP_HOST), int(env_port or DEFAULT_HTTP_PORT)
113
+
114
+ workspace_root = WorkspaceManager.resolve_workspace_root()
115
+
116
+ # 1. Try Global Registry
117
+ try:
118
+ inst = ServerRegistry().get_instance(workspace_root)
119
+ if inst and inst.get("port"):
120
+ return str(inst.get("host", DEFAULT_HTTP_HOST)), int(inst["port"])
121
+ except Exception:
122
+ pass
123
+
124
+ # 2. Legacy server.json
125
+ server_info = _load_server_info(workspace_root)
126
+ if server_info:
127
+ try:
128
+ return str(server_info.get("host", DEFAULT_HTTP_HOST)), int(server_info.get("port", DEFAULT_HTTP_PORT))
129
+ except Exception:
130
+ pass
131
+
132
+ # 3. Fallback to Config
133
+ cfg = _load_http_config(workspace_root) or {}
134
+ host = str(cfg.get("http_api_host", cfg.get("server_host", DEFAULT_HTTP_HOST)))
135
+ port = int(cfg.get("http_api_port", cfg.get("server_port", DEFAULT_HTTP_PORT)))
136
+ return host, port
137
+
138
+
139
+
140
+ def _request_http(path: str, params: dict) -> dict:
141
+ host, port = _get_http_host_port()
142
+ _enforce_loopback(host)
143
+ qs = urllib.parse.urlencode(params)
144
+ url = f"http://{host}:{port}{path}?{qs}"
145
+ with urllib.request.urlopen(url, timeout=3.0) as r:
146
+ return json.loads(r.read().decode("utf-8"))
147
+
148
+
149
+ def is_daemon_running(host: str, port: int) -> bool:
150
+ """Check if daemon is running by attempting connection."""
151
+ try:
152
+ with socket.create_connection((host, port), timeout=0.5):
153
+ return True
154
+ except (ConnectionRefusedError, OSError):
155
+ return False
156
+
157
+
158
+ def read_pid() -> Optional[int]:
159
+ """Read PID from pidfile."""
160
+ if PID_FILE.exists():
161
+ try:
162
+ return int(PID_FILE.read_text().strip())
163
+ except (ValueError, OSError):
164
+ pass
165
+ return None
166
+
167
+
168
+ def remove_pid() -> None:
169
+ """Remove pidfile manually (if daemon crashed)."""
170
+ if PID_FILE.exists():
171
+ PID_FILE.unlink()
172
+
173
+
174
+ def cmd_daemon_start(args):
175
+ """Start the daemon."""
176
+ host, port = get_daemon_address()
177
+
178
+ if is_daemon_running(host, port):
179
+ print(f"✅ Daemon already running on {host}:{port}")
180
+ return 0
181
+
182
+ repo_root = Path(__file__).parent.parent.resolve()
183
+
184
+ if args.daemonize:
185
+ # Start in background
186
+ print(f"Starting daemon on {host}:{port} (background)...")
187
+
188
+ proc = subprocess.Popen(
189
+ [sys.executable, "-m", "mcp.daemon"],
190
+ cwd=repo_root,
191
+ start_new_session=True,
192
+ stdout=subprocess.DEVNULL,
193
+ stderr=subprocess.DEVNULL
194
+ )
195
+
196
+ # PID file will be written by the daemon process itself
197
+
198
+ # Wait for startup
199
+ for _ in range(30):
200
+ if is_daemon_running(host, port):
201
+ print(f"✅ Daemon started (PID: {proc.pid})")
202
+ return 0
203
+ time.sleep(0.1)
204
+
205
+ print("❌ Daemon failed to start", file=sys.stderr)
206
+ return 1
207
+ else:
208
+ # Start in foreground
209
+ print(f"Starting daemon on {host}:{port} (foreground, Ctrl+C to stop)...")
210
+
211
+ try:
212
+ # Import and run directly
213
+ from mcp.daemon import main as daemon_main
214
+ import asyncio
215
+ asyncio.run(daemon_main())
216
+ except KeyboardInterrupt:
217
+ print("\nDaemon stopped.")
218
+
219
+ return 0
220
+
221
+
222
+ def cmd_daemon_stop(args):
223
+ """Stop the daemon."""
224
+ host, port = get_daemon_address()
225
+
226
+ if not is_daemon_running(host, port):
227
+ print("Daemon is not running")
228
+ remove_pid()
229
+ return 0
230
+
231
+ pid = read_pid()
232
+
233
+ if pid:
234
+ try:
235
+ if os.name == 'nt':
236
+ # Windows force kill
237
+ subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True, check=False)
238
+ print(f"Executed taskkill for PID {pid}")
239
+ else:
240
+ os.kill(pid, signal.SIGTERM)
241
+ print(f"Sent SIGTERM to PID {pid}")
242
+
243
+ # Wait for shutdown
244
+ for _ in range(30):
245
+ if not is_daemon_running(host, port):
246
+ print("✅ Daemon stopped")
247
+ remove_pid()
248
+ return 0
249
+ time.sleep(0.1)
250
+
251
+ # Force kill (Unix only, Windows already done with /F)
252
+ if os.name != 'nt':
253
+ print("Daemon not responding, sending SIGKILL...")
254
+ os.kill(pid, signal.SIGKILL)
255
+
256
+ remove_pid()
257
+ return 0
258
+
259
+ except (ProcessLookupError, PermissionError):
260
+ print("PID not found or permission denied, daemon may have crashed or locked")
261
+ remove_pid()
262
+ return 0
263
+ else:
264
+ print("No PID file found. Try stopping manually.")
265
+ return 1
266
+
267
+
268
+ def cmd_daemon_status(args):
269
+ """Check daemon status."""
270
+ host, port = get_daemon_address()
271
+
272
+ running = is_daemon_running(host, port)
273
+ pid = read_pid()
274
+
275
+ print(f"Host: {host}")
276
+ print(f"Port: {port}")
277
+ print(f"Status: {'🟢 Running' if running else '⚫ Stopped'}")
278
+
279
+ if pid:
280
+ print(f"PID: {pid}")
281
+
282
+ if running:
283
+ # Try to get workspace info
284
+ try:
285
+ with socket.create_connection((host, port), timeout=1.0) as sock:
286
+ # Send a status request
287
+ import json
288
+ request = json.dumps({
289
+ "jsonrpc": "2.0",
290
+ "id": 1,
291
+ "method": "tools/call",
292
+ "params": {"name": "status", "arguments": {"details": True}}
293
+ }) + "\n"
294
+ sock.sendall(request.encode())
295
+
296
+ # We'd need an initialize first, so just skip detailed status for now
297
+ except Exception:
298
+ pass
299
+
300
+ return 0 if running else 1
301
+
302
+
303
+ def cmd_proxy(args):
304
+ """Run in proxy mode (for MCP stdio)."""
305
+ from mcp.proxy import main as proxy_main
306
+ proxy_main()
307
+
308
+
309
+ def _tcp_blocked(err: OSError) -> bool:
310
+ return getattr(err, "errno", None) in (1, 13)
311
+
312
+
313
+ def cmd_auto(args):
314
+ """Try TCP daemon/proxy first, fallback to STDIO server."""
315
+ host, port = get_daemon_address()
316
+
317
+ # Fast path: if TCP is blocked by sandbox, skip daemon/proxy.
318
+ try:
319
+ with socket.create_connection((host, port), timeout=0.1):
320
+ return cmd_proxy(args)
321
+ except OSError as e:
322
+ if _tcp_blocked(e):
323
+ from mcp.server import main as server_main
324
+ server_main()
325
+ return 0
326
+ # Connection refused etc. We'll try to start daemon below.
327
+
328
+ # Try to start daemon in background, then proxy.
329
+ if not is_daemon_running(host, port):
330
+ repo_root = Path(__file__).parent.parent.resolve()
331
+ subprocess.Popen(
332
+ [sys.executable, "-m", "mcp.daemon"],
333
+ cwd=repo_root,
334
+ start_new_session=True,
335
+ stdout=subprocess.DEVNULL,
336
+ stderr=subprocess.DEVNULL,
337
+ )
338
+ for _ in range(30):
339
+ try:
340
+ if is_daemon_running(host, port):
341
+ break
342
+ except OSError as e:
343
+ if _tcp_blocked(e):
344
+ from mcp.server import main as server_main
345
+ server_main()
346
+ return 0
347
+ time.sleep(0.1)
348
+
349
+ if is_daemon_running(host, port):
350
+ return cmd_proxy(args)
351
+
352
+ # Final fallback to STDIO server.
353
+ from mcp.server import main as server_main
354
+ server_main()
355
+ return 0
356
+
357
+
358
+ def cmd_status(args):
359
+ """Query HTTP status endpoint."""
360
+ try:
361
+ host, port = _get_http_host_port()
362
+ # Fast check if port is even open
363
+ if not is_daemon_running(host, port):
364
+ print(f"❌ Error: Daemon is not running on {host}:{port}")
365
+ return 1
366
+
367
+ data = _request_http("/status", {})
368
+ print(json.dumps(data, ensure_ascii=False, indent=2))
369
+ return 0
370
+ except Exception as e:
371
+ print(f"❌ Error: Could not connect to Sari HTTP server.")
372
+ print(f" Details: {e}")
373
+ print(f" Hint: Make sure the Deamon is running for this workspace.")
374
+ return 1
375
+
376
+
377
+ def cmd_search(args):
378
+ """Query HTTP search endpoint."""
379
+ params = {"q": args.query, "limit": args.limit}
380
+ if args.repo:
381
+ params["repo"] = args.repo
382
+ data = _request_http("/search", params)
383
+ print(json.dumps(data, ensure_ascii=False, indent=2))
384
+ return 0
385
+
386
+
387
+ def cmd_init(args):
388
+ """Initialize workspace with Sari config."""
389
+ workspace_root = Path(args.workspace).expanduser().resolve() if args.workspace else Path(WorkspaceManager.resolve_workspace_root()).resolve()
390
+ cfg_path = Path(WorkspaceManager.resolve_config_path(str(workspace_root)))
391
+ data_dir = workspace_root / ".codex" / "tools" / "sari" / "data"
392
+
393
+ cfg_path.parent.mkdir(parents=True, exist_ok=True)
394
+ data_dir.mkdir(parents=True, exist_ok=True)
395
+
396
+ from app.config import Config
397
+ default_cfg = Config.get_defaults(str(workspace_root))
398
+
399
+ data = {}
400
+ if cfg_path.exists() and not args.force:
401
+ try:
402
+ data = json.loads(cfg_path.read_text(encoding="utf-8")) or {}
403
+ except Exception:
404
+ data = {}
405
+
406
+ roots = data.get("roots") or data.get("workspace_roots") or []
407
+ if not isinstance(roots, list):
408
+ roots = []
409
+ if str(workspace_root) not in roots:
410
+ roots.append(str(workspace_root))
411
+ data["roots"] = roots
412
+ data.setdefault("db_path", default_cfg["db_path"])
413
+ data.setdefault("exclude_dirs", default_cfg["exclude_dirs"])
414
+
415
+ cfg_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
416
+ print(f"✅ Updated Sari config: {cfg_path}")
417
+ print(f"\n🚀 Workspace initialized successfully at {workspace_root}")
418
+ return 0
419
+
420
+
421
+ def main():
422
+ parser = argparse.ArgumentParser(
423
+ prog="sari",
424
+ description="Sari - Local Search MCP Server"
425
+ )
426
+
427
+ subparsers = parser.add_subparsers(dest="command", help="Commands")
428
+
429
+ # daemon subcommand
430
+ daemon_parser = subparsers.add_parser("daemon", help="Daemon management")
431
+ daemon_sub = daemon_parser.add_subparsers(dest="daemon_command")
432
+
433
+ # daemon start
434
+ start_parser = daemon_sub.add_parser("start", help="Start daemon")
435
+ start_parser.add_argument("-d", "--daemonize", action="store_true",
436
+ help="Run in background")
437
+ start_parser.set_defaults(func=cmd_daemon_start)
438
+
439
+ # daemon stop
440
+ stop_parser = daemon_sub.add_parser("stop", help="Stop daemon")
441
+ stop_parser.set_defaults(func=cmd_daemon_stop)
442
+
443
+ # daemon status
444
+ status_parser = daemon_sub.add_parser("status", help="Check status")
445
+ status_parser.set_defaults(func=cmd_daemon_status)
446
+
447
+ # proxy subcommand
448
+ proxy_parser = subparsers.add_parser("proxy", help="Run in proxy mode")
449
+ proxy_parser.set_defaults(func=cmd_proxy)
450
+
451
+ # status subcommand (HTTP)
452
+ status_parser = subparsers.add_parser("status", help="Query HTTP status")
453
+ status_parser.set_defaults(func=cmd_status)
454
+
455
+ # search subcommand (HTTP)
456
+ search_parser = subparsers.add_parser("search", help="Search via HTTP server")
457
+ search_parser.add_argument("query", help="Search query")
458
+ search_parser.add_argument("--repo", default="", help="Limit search to repo")
459
+ search_parser.add_argument("--limit", type=int, default=10, help="Max results (default: 10)")
460
+ search_parser.set_defaults(func=cmd_search)
461
+
462
+ # init subcommand
463
+ init_parser = subparsers.add_parser("init", help="Initialize workspace config")
464
+ init_parser.add_argument("--workspace", default="", help="Workspace root (default: auto-detect)")
465
+ init_parser.add_argument("--force", action="store_true", help="Overwrite existing config")
466
+ init_parser.set_defaults(func=cmd_init)
467
+
468
+ args = parser.parse_args()
469
+
470
+ if not args.command:
471
+ parser.print_help()
472
+ return 1
473
+
474
+ if args.command == "daemon" and not args.daemon_command:
475
+ daemon_parser.print_help()
476
+ return 1
477
+
478
+ if hasattr(args, "func"):
479
+ return args.func(args)
480
+
481
+ return 0
482
+
483
+
484
+ if __name__ == "__main__":
485
+ sys.exit(main())
mcp/daemon.py ADDED
@@ -0,0 +1,149 @@
1
+ import asyncio
2
+ import os
3
+ import signal
4
+ import logging
5
+ import ipaddress
6
+ from .session import Session
7
+
8
+ from pathlib import Path
9
+ from app.workspace import WorkspaceManager
10
+
11
+ def _resolve_log_dir() -> Path:
12
+ for env_key in ["DECKARD_LOG_DIR", "LOCAL_SEARCH_LOG_DIR"]:
13
+ val = (os.environ.get(env_key) or "").strip()
14
+ if val:
15
+ return Path(os.path.expanduser(val)).resolve()
16
+ return WorkspaceManager.get_global_data_dir()
17
+
18
+ def _init_logging() -> None:
19
+ log_dir = _resolve_log_dir()
20
+ handlers = [logging.StreamHandler()]
21
+ try:
22
+ log_dir.mkdir(parents=True, exist_ok=True)
23
+ handlers.insert(0, logging.FileHandler(log_dir / "daemon.log"))
24
+ except Exception:
25
+ # Fall back to /tmp if the default log dir is not writable.
26
+ try:
27
+ tmp_dir = Path(os.environ.get("TMPDIR", "/tmp")) / "sari"
28
+ tmp_dir.mkdir(parents=True, exist_ok=True)
29
+ handlers.insert(0, logging.FileHandler(tmp_dir / "daemon.log"))
30
+ except Exception:
31
+ pass
32
+
33
+ logging.basicConfig(
34
+ level=logging.INFO,
35
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
36
+ handlers=handlers,
37
+ )
38
+
39
+ _init_logging()
40
+ logger = logging.getLogger("mcp-daemon")
41
+
42
+ DEFAULT_HOST = "127.0.0.1"
43
+ DEFAULT_PORT = 47779
44
+ PID_FILE = WorkspaceManager.get_global_data_dir() / "daemon.pid"
45
+
46
+ class SariDaemon:
47
+ def __init__(self):
48
+ self.host = os.environ.get("DECKARD_DAEMON_HOST", DEFAULT_HOST)
49
+ self.port = int(os.environ.get("DECKARD_DAEMON_PORT", DEFAULT_PORT))
50
+ self.server = None
51
+
52
+ def _write_pid(self):
53
+ """Write current PID to file."""
54
+ try:
55
+ pid = os.getpid()
56
+ PID_FILE.parent.mkdir(parents=True, exist_ok=True)
57
+ PID_FILE.write_text(str(pid))
58
+ logger.info(f"Wrote PID {pid} to {PID_FILE}")
59
+ except Exception as e:
60
+ logger.error(f"Failed to write PID file: {e}")
61
+
62
+ def _remove_pid(self):
63
+ """Remove PID file."""
64
+ try:
65
+ if PID_FILE.exists():
66
+ PID_FILE.unlink()
67
+ logger.info("Removed PID file")
68
+ except Exception as e:
69
+ logger.error(f"Failed to remove PID file: {e}")
70
+
71
+ async def start(self):
72
+ allow_non_loopback = os.environ.get("DECKARD_ALLOW_NON_LOOPBACK") == "1" or os.environ.get("LOCAL_SEARCH_ALLOW_NON_LOOPBACK") == "1"
73
+ host = (self.host or "127.0.0.1").strip()
74
+ try:
75
+ is_loopback = host.lower() == "localhost" or ipaddress.ip_address(host).is_loopback
76
+ except ValueError:
77
+ is_loopback = host.lower() == "localhost"
78
+
79
+ if (not is_loopback) and (not allow_non_loopback):
80
+ raise SystemExit(
81
+ f"sari daemon refused to start: host must be loopback only (127.0.0.1/localhost/::1). got={host}. "
82
+ "Set DECKARD_ALLOW_NON_LOOPBACK=1 to override (NOT recommended)."
83
+ )
84
+
85
+ self._write_pid()
86
+
87
+ self.server = await asyncio.start_server(
88
+ self.handle_client, self.host, self.port
89
+ )
90
+
91
+ addr = self.server.sockets[0].getsockname()
92
+ logger.info(f"Sari Daemon serving on {addr}")
93
+
94
+ async with self.server:
95
+ await self.server.serve_forever()
96
+
97
+ async def handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter):
98
+ addr = writer.get_extra_info('peername')
99
+ logger.info(f"Accepted connection from {addr}")
100
+
101
+ session = Session(reader, writer)
102
+ await session.handle_connection()
103
+
104
+ logger.info(f"Closed connection from {addr}")
105
+
106
+ def shutdown(self):
107
+ if self.server:
108
+ self.server.close()
109
+
110
+ # Shutdown all workspaces to stop indexers and close DBs
111
+ from .registry import Registry
112
+ Registry.get_instance().shutdown_all()
113
+
114
+ self._remove_pid()
115
+
116
+ async def main():
117
+ daemon = SariDaemon()
118
+
119
+ # Handle signals
120
+ loop = asyncio.get_running_loop()
121
+ stop = asyncio.Future()
122
+
123
+ def _handle_signal():
124
+ stop.set_result(None)
125
+
126
+ loop.add_signal_handler(signal.SIGTERM, _handle_signal)
127
+ loop.add_signal_handler(signal.SIGINT, _handle_signal)
128
+
129
+ daemon_task = asyncio.create_task(daemon.start())
130
+
131
+ logger.info("Daemon started. Press Ctrl+C to stop.")
132
+
133
+ try:
134
+ await stop
135
+ finally:
136
+ logger.info("Stopping daemon...")
137
+ daemon.shutdown()
138
+ # Wait for server to close? asyncio.start_server manages this in async with
139
+ # but we created a task.
140
+ # Actually server.serve_forever() runs until cancelled.
141
+ daemon_task.cancel()
142
+ try:
143
+ await daemon_task
144
+ except asyncio.CancelledError:
145
+ pass
146
+ logger.info("Daemon stopped.")
147
+
148
+ if __name__ == "__main__":
149
+ asyncio.run(main())