saral 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.
saral/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Saral: a private, voice-first AI helper that runs at home.
2
+
3
+ Design rule behind every job: extract facts deterministically, let the AI
4
+ explain them, and let the human decide.
5
+ """
6
+
7
+ __version__ = "0.1.0"
saral/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
saral/app.py ADDED
@@ -0,0 +1,129 @@
1
+ """The one-click app: what runs when someone double-clicks Saral.
2
+
3
+ It starts the local server (reachable by paired phones on home Wi-Fi), starts the
4
+ bundled AI engine when the model is present, and opens the browser on the setup
5
+ screen the first time or on Saral itself afterwards. If Saral is already running,
6
+ double-clicking just opens the window again.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import contextlib
13
+ import logging
14
+ import socket
15
+ import sys
16
+ import threading
17
+ import time
18
+ import webbrowser
19
+
20
+ import httpx
21
+
22
+ from .config import Settings, data_dir
23
+
24
+ DEFAULT_PORT = 8765
25
+ log = logging.getLogger("saral")
26
+
27
+
28
+ def _setup_logging() -> None:
29
+ # Windowed builds have no console: keep a small log file for troubleshooting.
30
+ log_path = data_dir() / "saral.log"
31
+ handlers: list[logging.Handler] = [logging.FileHandler(log_path, encoding="utf-8")]
32
+ if sys.stderr is not None:
33
+ handlers.append(logging.StreamHandler())
34
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s", handlers=handlers)
35
+ logging.getLogger("httpx").setLevel(logging.WARNING)
36
+ if sys.stdout is None:
37
+ sys.stdout = open(log_path, "a", encoding="utf-8") # noqa: SIM115
38
+ if sys.stderr is None:
39
+ sys.stderr = sys.stdout
40
+
41
+
42
+ def saral_running(port: int) -> bool:
43
+ try:
44
+ r = httpx.get(f"http://127.0.0.1:{port}/api/health", timeout=1.0)
45
+ return r.status_code == 200 and "version" in r.json()
46
+ except (httpx.HTTPError, ValueError):
47
+ return False
48
+
49
+
50
+ def port_free(port: int) -> bool:
51
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
52
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
53
+ try:
54
+ s.bind(("0.0.0.0", port))
55
+ except OSError:
56
+ return False
57
+ return True
58
+
59
+
60
+ def choose_port() -> int:
61
+ """Keep the same port across restarts so paired phones keep working."""
62
+ saved = data_dir() / "port"
63
+ with contextlib.suppress(ValueError, OSError):
64
+ port = int(saved.read_text().strip())
65
+ if port_free(port):
66
+ return port
67
+ for port in range(DEFAULT_PORT, DEFAULT_PORT + 20):
68
+ if port_free(port):
69
+ saved.write_text(str(port))
70
+ return port
71
+ raise OSError("No free port for Saral between 8765 and 8784.")
72
+
73
+
74
+ def open_window(port: int, path: str) -> None:
75
+ webbrowser.open(f"http://127.0.0.1:{port}{path}")
76
+
77
+
78
+ def run_app(background: bool = False) -> int:
79
+ _setup_logging()
80
+ saved = data_dir() / "port"
81
+ with contextlib.suppress(ValueError, OSError):
82
+ existing = int(saved.read_text().strip())
83
+ if saral_running(existing):
84
+ if not background:
85
+ open_window(existing, "/")
86
+ return 0
87
+
88
+ import uvicorn
89
+
90
+ from .server import create_app
91
+
92
+ settings = Settings.from_env()
93
+ settings.port = choose_port()
94
+ settings.host = "0.0.0.0" # paired phones on home Wi-Fi; the token protects the API
95
+ app = create_app(settings)
96
+ rt = app.state.runtime
97
+ server = uvicorn.Server(
98
+ uvicorn.Config(app, host=settings.host, port=settings.port, log_level="warning")
99
+ )
100
+ rt.on_quit.append(lambda: setattr(server, "should_exit", True))
101
+
102
+ def open_when_ready() -> None:
103
+ for _ in range(100):
104
+ if server.started:
105
+ break
106
+ time.sleep(0.1)
107
+ if not background:
108
+ open_window(settings.port, "/" if rt.model_installed() else "/setup")
109
+
110
+ threading.Thread(target=open_when_ready, daemon=True).start()
111
+ log.info("Saral started on port %s (tier %s)", settings.port, rt.report.tier)
112
+ try:
113
+ server.run()
114
+ finally:
115
+ if rt.engine_manager:
116
+ rt.engine_manager.stop()
117
+ log.info("Saral stopped")
118
+ return 0
119
+
120
+
121
+ def main(argv: list[str] | None = None) -> int:
122
+ parser = argparse.ArgumentParser(prog="saral-app")
123
+ parser.add_argument("--background", action="store_true", help="start without opening a window")
124
+ args, _ = parser.parse_known_args(argv)
125
+ return run_app(background=args.background)
126
+
127
+
128
+ if __name__ == "__main__":
129
+ raise SystemExit(main())
saral/autostart.py ADDED
@@ -0,0 +1,77 @@
1
+ """Start Saral when the computer turns on, so nobody has to remember to open it.
2
+
3
+ Windows: a tiny script in the Startup folder. macOS: a LaunchAgent.
4
+ Linux: an XDG autostart entry. All are per-user and easy to remove.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import platform
11
+ import plistlib
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ LABEL = "org.saral.app"
16
+
17
+
18
+ def launch_command() -> list[str]:
19
+ """How to start Saral in the background (no browser window)."""
20
+ if getattr(sys, "frozen", False):
21
+ return [sys.executable, "--background"]
22
+ return [sys.executable, "-m", "saral", "app", "--background"]
23
+
24
+
25
+ def _windows_path() -> Path:
26
+ appdata = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
27
+ return appdata / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup" / "Saral.vbs"
28
+
29
+
30
+ def _mac_path() -> Path:
31
+ return Path.home() / "Library" / "LaunchAgents" / f"{LABEL}.plist"
32
+
33
+
34
+ def _linux_path() -> Path:
35
+ base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
36
+ return base / "autostart" / "saral.desktop"
37
+
38
+
39
+ def entry_path() -> Path:
40
+ system = platform.system()
41
+ if system == "Windows":
42
+ return _windows_path()
43
+ if system == "Darwin":
44
+ return _mac_path()
45
+ return _linux_path()
46
+
47
+
48
+ def is_enabled() -> bool:
49
+ return entry_path().exists()
50
+
51
+
52
+ def enable() -> Path:
53
+ path = entry_path()
54
+ path.parent.mkdir(parents=True, exist_ok=True)
55
+ cmd = launch_command()
56
+ system = platform.system()
57
+ if system == "Windows":
58
+ # VBScript runs the app with no console window (style 0 = hidden).
59
+ quoted = " ".join(f'""{part}""' for part in cmd)
60
+ path.write_text(f'CreateObject("WScript.Shell").Run "{quoted}", 0, False\r\n')
61
+ elif system == "Darwin":
62
+ with path.open("wb") as fh:
63
+ plistlib.dump(
64
+ {"Label": LABEL, "ProgramArguments": cmd, "RunAtLoad": True, "KeepAlive": False},
65
+ fh,
66
+ )
67
+ else:
68
+ exec_line = " ".join(f'"{part}"' if " " in part else part for part in cmd)
69
+ path.write_text(
70
+ "[Desktop Entry]\nType=Application\nName=Saral\n"
71
+ f"Exec={exec_line}\nX-GNOME-Autostart-enabled=true\nNoDisplay=false\n"
72
+ )
73
+ return path
74
+
75
+
76
+ def disable() -> None:
77
+ entry_path().unlink(missing_ok=True)
saral/binaries.py ADDED
@@ -0,0 +1,124 @@
1
+ """Find tools that ship inside the Saral installer (llama-server, Tesseract).
2
+
3
+ Search order: an explicit environment variable, the folder bundled inside the
4
+ installed app, Saral's data folder, then the system PATH (for developers).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from .config import data_dir
17
+
18
+ IS_WINDOWS = os.name == "nt"
19
+
20
+
21
+ def app_root() -> Path:
22
+ """Folder of the installed app (PyInstaller) or the source package."""
23
+ frozen = getattr(sys, "_MEIPASS", None)
24
+ return Path(frozen) if frozen else Path(__file__).resolve().parent
25
+
26
+
27
+ def bundled_dir() -> Path:
28
+ return app_root() / "bin"
29
+
30
+
31
+ def is_frozen() -> bool:
32
+ return bool(getattr(sys, "frozen", False))
33
+
34
+
35
+ def _exe(name: str) -> str:
36
+ return f"{name}.exe" if IS_WINDOWS else name
37
+
38
+
39
+ def find_tool(name: str, env_var: str | None = None, subdir: str | None = None) -> Path | None:
40
+ if env_var and os.environ.get(env_var):
41
+ path = Path(os.environ[env_var])
42
+ return path if path.exists() else None
43
+ folders = [bundled_dir(), data_dir() / "bin"]
44
+ if subdir:
45
+ folders = [f / subdir for f in folders] + folders
46
+ for folder in folders:
47
+ candidate = folder / _exe(name)
48
+ if candidate.exists():
49
+ return candidate
50
+ found = shutil.which(name)
51
+ return Path(found) if found else None
52
+
53
+
54
+ # Oldest llama.cpp build Saral trusts on the system PATH (Qwen3 + chat template options).
55
+ MIN_LLAMA_BUILD = int(os.environ.get("SARAL_MIN_LLAMA_BUILD", "5300"))
56
+ _version_cache: dict[tuple[str, float], int | None] = {}
57
+
58
+
59
+ def llama_version(path: Path) -> int | None:
60
+ """llama.cpp build number from `llama-server --version`, or None if unknown."""
61
+ try:
62
+ key = (str(path), path.stat().st_mtime)
63
+ except OSError:
64
+ return None
65
+ if key not in _version_cache:
66
+ version = None
67
+ try:
68
+ out = subprocess.run(
69
+ [str(path), "--version"],
70
+ capture_output=True,
71
+ text=True,
72
+ timeout=15,
73
+ creationflags=0x08000000 if IS_WINDOWS else 0,
74
+ )
75
+ match = re.search(r"version:?\s*b?(\d+)", out.stdout + out.stderr)
76
+ version = int(match.group(1)) if match else None
77
+ except (OSError, subprocess.SubprocessError):
78
+ version = None
79
+ _version_cache[key] = version
80
+ return _version_cache[key]
81
+
82
+
83
+ def _skip_system_flag() -> Path:
84
+ return data_dir() / "skip_system_engine"
85
+
86
+
87
+ def mark_system_engine_bad(path: Path) -> None:
88
+ """Remember that the PATH llama-server failed, so Saral fetches its own next time."""
89
+ _skip_system_flag().write_text(str(path))
90
+
91
+
92
+ def llama_server_info() -> tuple[Path | None, str]:
93
+ """The engine to use and where it came from: env | bundled | saral | system | none."""
94
+ if os.environ.get("SARAL_LLAMA_SERVER"):
95
+ path = Path(os.environ["SARAL_LLAMA_SERVER"])
96
+ return (path, "env") if path.exists() else (None, "none")
97
+ for source, folder in (("bundled", bundled_dir()), ("saral", data_dir() / "bin")):
98
+ for candidate in (folder / "llama" / _exe("llama-server"), folder / _exe("llama-server")):
99
+ if candidate.exists():
100
+ return candidate, source
101
+ found = shutil.which("llama-server")
102
+ if found:
103
+ path = Path(found)
104
+ skipped = _skip_system_flag()
105
+ if skipped.exists() and skipped.read_text().strip() == str(path):
106
+ return None, "none"
107
+ version = llama_version(path)
108
+ if version is not None and version < MIN_LLAMA_BUILD:
109
+ return None, "none" # too old: Saral downloads its own instead
110
+ return path, "system"
111
+ return None, "none"
112
+
113
+
114
+ def llama_server() -> Path | None:
115
+ return llama_server_info()[0]
116
+
117
+
118
+ def tesseract() -> tuple[Path | None, Path | None]:
119
+ """Tesseract binary and, when bundled, its tessdata folder."""
120
+ binary = find_tool("tesseract", "SARAL_TESSERACT", subdir="tesseract")
121
+ if binary is None:
122
+ return None, None
123
+ tessdata = binary.parent / "tessdata"
124
+ return binary, (tessdata if tessdata.is_dir() else None)
saral/cli.py ADDED
@@ -0,0 +1,161 @@
1
+ """Command line: `saral doctor`, `saral serve`, `saral pull`, `saral eval`, `saral pair --reset`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+
9
+ from .config import Settings
10
+
11
+
12
+ def cmd_app(args: argparse.Namespace) -> int:
13
+ from .app import run_app
14
+
15
+ return run_app(background=args.background)
16
+
17
+
18
+ def cmd_doctor(_: argparse.Namespace) -> int:
19
+ from . import binaries
20
+ from .models import CATALOG, choose_tier, installed, model_path
21
+ from .ocr import tesseract_available
22
+ from .probe import probe
23
+ from .speech import speech_available
24
+
25
+ report = probe()
26
+ tier, reason = choose_tier(report.ram_gb, report.gpu_vram_gb)
27
+ model = CATALOG[tier]
28
+ engine = binaries.llama_server()
29
+ print("Saral doctor (for developers; families use the setup screen)\n")
30
+ print(f" Computer {report.os}, {report.cpu_cores} cores, {report.ram_gb} GB memory")
31
+ print(f" Disk free {report.free_disk_gb} GB")
32
+ print(f" GPU {report.gpu or 'none (CPU is fine)'}")
33
+ why = {"chosen": "your choice", "disk": "smaller: low disk space", "hardware": "fits"}[reason]
34
+ print(f" Model {model.filename} ({model.size_gb} GB, {why})")
35
+ print(f" AI engine {engine or 'llama-server not found'}")
36
+ print(f" AI model {model_path(model) if installed(model) else 'not downloaded yet'}")
37
+ print(f" Photo OCR {'ready' if tesseract_available() else 'not installed'}")
38
+ print(f" Voice input {'ready' if speech_available() else 'not installed'}")
39
+ for warning in report.warnings:
40
+ print(f"\n ! {warning}")
41
+ if engine is None:
42
+ print("\n Developers: put llama.cpp's llama-server on PATH or set SARAL_LLAMA_SERVER.")
43
+ if not installed(model):
44
+ print(" Get the model with: saral pull (or run `saral` and use the setup screen)")
45
+ return 0
46
+
47
+
48
+ def cmd_pull(_: argparse.Namespace) -> int:
49
+ import time
50
+
51
+ from .engine_fetch import EngineStep
52
+ from .models import CATALOG, ModelInstaller, choose_tier
53
+ from .probe import probe
54
+
55
+ report = probe()
56
+ model = CATALOG[choose_tier(report.ram_gb, report.gpu_vram_gb)[0]]
57
+ print(f"Downloading {model.filename} (about {model.size_gb} GB). It resumes if interrupted.")
58
+ installer = ModelInstaller(model, before=EngineStep()) # also fetches the engine if missing
59
+ installer.start_download()
60
+ while installer.busy:
61
+ st = installer.state.to_dict()
62
+ print(f"\r {st['status']:<11} {st['percent']:5.1f}%", end="", flush=True)
63
+ time.sleep(1)
64
+ print()
65
+ if installer.state.status != "done":
66
+ print(f"Failed: {installer.state.error}", file=sys.stderr)
67
+ return 1
68
+ print("Done.")
69
+ return 0
70
+
71
+
72
+ def cmd_serve(args: argparse.Namespace) -> int:
73
+ import uvicorn
74
+
75
+ from .pairing import get_or_create_token, lan_ip
76
+ from .server import create_app
77
+
78
+ settings = Settings.from_env()
79
+ if args.backend:
80
+ settings.backend = args.backend
81
+ if args.region:
82
+ settings.region = args.region.upper()
83
+ settings.port = args.port
84
+ settings.host = "0.0.0.0" if args.lan else "127.0.0.1"
85
+
86
+ app = create_app(settings)
87
+ print(f"\nSaral is running. On this computer open: http://127.0.0.1:{settings.port}")
88
+ if args.lan:
89
+ link = f"http://{lan_ip()}:{settings.port}/?pair={get_or_create_token()}"
90
+ print("\nOn your phone (same Wi-Fi), open this pairing link once:")
91
+ print(f" {link}")
92
+ try:
93
+ import qrcode
94
+
95
+ qr = qrcode.QRCode(border=1)
96
+ qr.add_data(link)
97
+ qr.print_ascii(invert=True)
98
+ except ImportError:
99
+ print(" (pip install 'saral[qr]' to show a QR code here)")
100
+ print("Keep this link private. Run `saral pair --reset` to unpair all phones.\n")
101
+ uvicorn.run(app, host=settings.host, port=settings.port, log_level="warning")
102
+ return 0
103
+
104
+
105
+ def cmd_pair(args: argparse.Namespace) -> int:
106
+ from .pairing import get_or_create_token, reset_token
107
+
108
+ token = reset_token() if args.reset else get_or_create_token()
109
+ print("All phones unpaired. New pairing token created." if args.reset else token)
110
+ return 0
111
+
112
+
113
+ def cmd_eval(args: argparse.Namespace) -> int:
114
+ from .evals import run_all
115
+
116
+ report = run_all(backend_name=args.backend)
117
+ print(json.dumps(report, indent=2, ensure_ascii=False))
118
+ return 0 if report["gate"]["passed"] else 1
119
+
120
+
121
+ def main(argv: list[str] | None = None) -> int:
122
+ parser = argparse.ArgumentParser(
123
+ prog="saral", description="Saral: your private helper at home."
124
+ )
125
+ sub = parser.add_subparsers(dest="command")
126
+
127
+ app = sub.add_parser("app", help="start Saral the easy way (default when no command)")
128
+ app.add_argument("--background", action="store_true", help="start without opening a window")
129
+ app.set_defaults(func=cmd_app)
130
+
131
+ sub.add_parser("doctor", help="check this computer and pick a model size").set_defaults(
132
+ func=cmd_doctor
133
+ )
134
+ sub.add_parser("pull", help="download the recommended model via Ollama").set_defaults(
135
+ func=cmd_pull
136
+ )
137
+
138
+ serve = sub.add_parser("serve", help="start Saral")
139
+ serve.add_argument("--lan", action="store_true", help="allow paired phones on home Wi-Fi")
140
+ serve.add_argument("--port", type=int, default=8765)
141
+ serve.add_argument("--backend", choices=["local", "ollama", "rules"])
142
+ serve.add_argument("--region", choices=["IN", "US", "in", "us"])
143
+ serve.set_defaults(func=cmd_serve)
144
+
145
+ pair = sub.add_parser("pair", help="show or reset the phone pairing token")
146
+ pair.add_argument("--reset", action="store_true", help="unpair every phone")
147
+ pair.set_defaults(func=cmd_pair)
148
+
149
+ ev = sub.add_parser("eval", help="run the eval sets and check the release gate")
150
+ ev.add_argument("--backend", choices=["rules", "ollama"], default="rules")
151
+ ev.set_defaults(func=cmd_eval)
152
+
153
+ args = parser.parse_args(argv)
154
+ if args.command is None:
155
+ # Double-click or plain `saral`: the one-click app.
156
+ return cmd_app(argparse.Namespace(background=False))
157
+ return args.func(args)
158
+
159
+
160
+ if __name__ == "__main__":
161
+ raise SystemExit(main())
saral/config.py ADDED
@@ -0,0 +1,84 @@
1
+ """Settings, hardware tiers and supported languages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Tier:
12
+ """A hardware tier and the model that runs well on it."""
13
+
14
+ name: str
15
+ min_ram_gb: float
16
+ model: str
17
+ approx_model_gb: float
18
+ note: str
19
+
20
+
21
+ # Ordered from smallest to largest. The probe picks the largest tier that fits.
22
+ # Qwen3 is Apache-2.0, trained on 119 languages, and ships in sizes for each tier.
23
+ TIERS: tuple[Tier, ...] = (
24
+ Tier("lite", 0, "qwen3:1.7b", 1.4, "Works on 8 GB laptops. Short answers, slower."),
25
+ Tier("standard", 12, "qwen3:4b", 2.6, "16 GB laptop or mini-PC. All jobs at usable speed."),
26
+ Tier("plus", 24, "qwen3:8b", 5.2, "32 GB or a GPU. Long documents, better reasoning."),
27
+ )
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Language:
32
+ code: str
33
+ english_name: str
34
+ native_name: str
35
+
36
+
37
+ LANGUAGES: dict[str, Language] = {
38
+ "en": Language("en", "English", "English"),
39
+ "hi": Language("hi", "Hindi", "हिन्दी"),
40
+ "te": Language("te", "Telugu", "తెలుగు"),
41
+ "es": Language("es", "Spanish", "Español"),
42
+ }
43
+
44
+ DEFAULT_LANGUAGE = "en"
45
+
46
+ # Region changes date order (03/04 = 3 April in India, March 4 in the US)
47
+ # and which emergency numbers we show.
48
+ REGIONS = ("IN", "US")
49
+ DEFAULT_REGION = "IN"
50
+
51
+
52
+ def data_dir() -> Path:
53
+ """Where Saral keeps its small amount of local state (the pairing token)."""
54
+ root = os.environ.get("SARAL_HOME")
55
+ path = Path(root) if root else Path.home() / ".saral"
56
+ path.mkdir(parents=True, exist_ok=True)
57
+ return path
58
+
59
+
60
+ @dataclass
61
+ class Settings:
62
+ """Runtime settings. Everything defaults to local-only."""
63
+
64
+ # "local" = bundled llama.cpp engine (default, nothing extra to install),
65
+ # "ollama" = an existing Ollama install (for developers),
66
+ # "rules" = no model, deterministic output only.
67
+ backend: str = "local"
68
+ model: str | None = None # None = pick from hardware tier
69
+ ollama_url: str = "http://127.0.0.1:11434"
70
+ allow_remote_model_host: bool = False # refuse to send data off this machine
71
+ region: str = DEFAULT_REGION
72
+ host: str = "127.0.0.1"
73
+ port: int = 8765
74
+ require_token_for_lan: bool = True
75
+ extra: dict = field(default_factory=dict)
76
+
77
+ @classmethod
78
+ def from_env(cls) -> Settings:
79
+ s = cls()
80
+ s.backend = os.environ.get("SARAL_BACKEND", s.backend)
81
+ s.model = os.environ.get("SARAL_MODEL") or None
82
+ s.ollama_url = os.environ.get("SARAL_OLLAMA_URL", s.ollama_url)
83
+ s.region = os.environ.get("SARAL_REGION", s.region).upper()
84
+ return s