From 02d48a304ac03c862656fd532597970b0d6b2add56545e1660347625680f50f6 Mon Sep 17 00:00:00 2001 From: jonas H Date: Sat, 12 Sep 2026 00:35:26 +0200 Subject: [PATCH] add fisr unaable version --- .gitignore | 6 + README.md | 88 +++++- client.py | 517 ++++++++++++++++++++++++++++++++ coder | 4 + server/config.toml.example | 24 ++ server/server.py | 590 +++++++++++++++++++++++++++++++++++++ server/sjcode.db-shm | Bin 0 -> 32768 bytes server/sjcode.db-wal | Bin 0 -> 28872 bytes 8 files changed, 1228 insertions(+), 1 deletion(-) create mode 100755 client.py create mode 100755 coder create mode 100644 server/config.toml.example create mode 100644 server/server.py create mode 100644 server/sjcode.db-shm create mode 100644 server/sjcode.db-wal diff --git a/.gitignore b/.gitignore index 3996e81..88f4f54 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ +# ---> sjcode +# Local daemon configuration and runtime data +config.toml +*.db +*.db-journal + # ---> Python # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/README.md b/README.md index faee866..b5d12d4 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,89 @@ # sjcode -ein terminal coding agent, der die ollama API nutzt \ No newline at end of file +ein terminal coding agent, der die ollama API nutzt + +## Setup + +```bash +cp config.toml.example config.toml # then edit port / model / limits +python3 server/server.py # starts the daemon + admin CLI +``` + +`config.toml` is machine-local and git-ignored; `config.toml.example` is the +template. On first start the daemon creates the SQLite database (`sjcode.db`). + +## Daemon (server.py) + +On startup it prints its settings and drops into a minimal admin CLI +(accounts are stored in SQLite): + +| command | effect | +|--------------------------|-------------------------------------------------| +| `create [role]` | create account (asks for password, default role `user`) | +| `list` | show all accounts | +| `delete ` | remove account | +| `role ` | change role | +| `passwd ` | set a new password | +| `usage [name]` | used minutes today vs. limit | +| `reset [name]` | reset the daily usage counter | +| `status` | show daemon settings | +| `quit` | stop the daemon | + +While the CLI runs, an HTTP API is served in the background: + +- `POST /login` — `{"username", "password"}` → `{"token", ...}` +- `POST /chat` — `{"token", "message", "model"?}` → `{"response", ...}` +- `GET /models?token=...` — list ollama models + +Every user row stores: `username`, `password_hash`, `last_logged_in`, +`role`, `used_today` (minutes of AI usage; resets on a new day). + +## Roles and limits + +In `config.toml` each role gets a daily limit in minutes: + +```toml +[limits] +user = 120 # default role for new accounts +admin = -1 # negative = unlimited +``` + +Login and every chat request are blocked once `used_today` reaches the limit. + +## Client (client.py) + +```bash +./coder # or: python3 client.py +python3 client.py 10.0.0.5:8420 # explicit daemon address +python3 client.py --model qwen3-coder # skip the model picker +python3 client.py --no-probe # skip the reachability check (use behind + # HTTP proxies that answer on every port) +``` + +On start the client asks which server to connect to: pick one of the saved +ones, or add a new one (`n`). Every server you use is saved in +`~/.config/sjcode/client.json` together with the username and the last +model, so the next start is just Enter-Enter. Addresses without a port use +8420; `host:port`, `http(s)://...` URLs and IPv6 literals are accepted. + +After login and model choice you get the agent banner and a chat REPL. +Commands: + +| command | effect | +|---------------|-----------------------------------------| +| `/help` | list commands | +| `/server` | show the connected server | +| `/models` | list models on the server | +| `/model ` | switch model (saved for this server) | +| `/limit` | remaining minutes today (last known) | +| `/clear` | forget the conversation history | +| `/quit` | exit | + +Anything else is sent to the model. The client keeps the last few turns as +context (the daemon API itself is stateless) and renders the reply in the +agent style (`✓` lines for actions the model reports). + +## Requirements + +- Python 3.11+ (stdlib only; `tomllib` is included) +- ollama listening on the address from `config.toml` diff --git a/client.py b/client.py new file mode 100755 index 0000000..0bb85d3 --- /dev/null +++ b/client.py @@ -0,0 +1,517 @@ +#!/usr/bin/env python3 +"""sjcode client. + +Terminal coding-agent front end for the sjcode daemon (server/server.py). + +On start it asks which server to connect to. Servers you used before are +remembered in ~/.config/sjcode/client.json together with username and the +last model, so next time you just press Enter. New servers are saved too. + +Flow: + coder -> banner, pick saved server (or add one), + login, pick model, chat REPL + coder 10.0.0.5:8420 -> skip the server picker + coder --model qwen3-coder -> skip the model picker + +REPL commands: + /help /server /model /models /limit /clear /quit +""" + +from __future__ import annotations + +import argparse +import getpass +import json +import os +import re +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "sjcode" +CONFIG_FILE = CONFIG_DIR / "client.json" + +DEFAULT_PORT = 8420 +REQUEST_TIMEOUT = 300 +HISTORY_TURNS = 10 # prior turns sent with each message for context + +HOST_RE = re.compile( + r"^(?=.{1,253}\z)" # total length limit + r"(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+" # domain labels + + r"[a-z]{2,63}\z" # a TLD + r"|^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\z" # or single-label hostname + r"|^(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}\z" # IPv4 + r"|^(?:[0-9a-f]{0,4}:){2,7}[0-9a-f]{0,4}\z", # IPv6 (2+ colons) + re.IGNORECASE, +) + +# Makes the model behave like a terminal agent: short lines, check marks. +AGENT_PREAMBLE = ( + "You are sj, a coding agent working in the user's terminal.\n" + "Answer concisely and practically.\n" + "Describe what you do as short lines; put every action you performed " + "or verified on its own line starting with '\u2713 ' (check mark).\n" + "End your reply with the single line 'Done.'" +) + + +# --------------------------------------------------------------------------- +# small terminal helpers (ANSI, no dependencies) +# --------------------------------------------------------------------------- + +class Style: + def __init__(self, enabled: bool) -> None: + self.dim = "\033[2m" if enabled else "" + self.bold = "\033[1m" if enabled else "" + self.green = "\033[32m" if enabled else "" + self.yellow = "\033[33m" if enabled else "" + self.red = "\033[31m" if enabled else "" + self.cyan = "\033[36m" if enabled else "" + self.reset = "\033[0m" if enabled else "" + + +def style_enabled() -> bool: + if os.environ.get("NO_COLOR"): + return False + return sys.stdout.isatty() + + +ST = Style(style_enabled()) + + +def ask(prompt: str) -> str: + """input() that exits cleanly on Ctrl+D instead of crashing.""" + try: + return input(prompt) + except EOFError: + print() + sys.exit(1) + + +def banner(model: str, project: str, server: str) -> None: + title = "sj Coding Agent" + lines = [f"Model: {model}", f"Project: {project}"] + width = max(len(title), *(len(l) for l in lines)) + 2 + print(f"\u256d{'\u2500' * (width + 2)}\u256e") + print(f"\u2502 {title.center(width)} \u2502") + for line in lines: + print(f"\u2502 {line.ljust(width)} \u2502") + print(f"\u2570{'\u2500' * (width + 2)}\u256f") + print(f"{ST.dim}server: {server} (\u2018/help\u2019 for commands){ST.reset}") + + +def shorten_home(path: Path) -> str: + home = Path.home() + try: + return "~" / path.relative_to(home) + except ValueError: + return path + + +# --------------------------------------------------------------------------- +# saved servers +# --------------------------------------------------------------------------- + +def load_state() -> dict: + try: + with open(CONFIG_FILE, encoding="utf-8") as fh: + data = json.load(fh) + if isinstance(data, dict) and isinstance(data.get("servers"), list): + return data + except (OSError, json.JSONDecodeError): + pass + return {"servers": [], "last": ""} + + +def save_state(state: dict) -> None: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + with open(CONFIG_FILE, "w", encoding="utf-8") as fh: + json.dump(state, fh, indent=2) + + +def server_label(entry: dict) -> str: + return f"{entry['host']}:{entry['port']}" + + +def find_server(state: dict, host: str, port: int) -> dict | None: + for entry in state["servers"]: + if entry["host"] == host and int(entry["port"]) == port: + return entry + return None + + +def parse_address(text: str) -> tuple[str, int]: + """Accept 'domain', 'ip', 'host:port', IPv6 or a full http(s) URL.""" + text = text.strip() + if "://" in text: + parsed = urllib.parse.urlsplit(text) + host, port = parsed.hostname or "", parsed.port or DEFAULT_PORT + elif text.startswith("["): # [ipv6]:port + parsed = urllib.parse.urlsplit("//" + text) + host, port = parsed.hostname or "", parsed.port or DEFAULT_PORT + elif text.count(":") > 1: # bare IPv6 literal (no port part) + host, port = text, DEFAULT_PORT + else: + host, _, port_s = text.rpartition(":") + if not host or not port_s.isdigit() or not (0 < int(port_s) < 65536): + host, port = text, DEFAULT_PORT + else: + port = int(port_s) + host = host.strip("[]").strip().lower() + if not host or not HOST_RE.match(host): + raise ValueError(f"not a valid host: {text!r}") + return host, port + + +def pick_server(state: dict) -> tuple[dict, bool]: + """Ask which server to use. Returns (entry, created_new).""" + servers = state["servers"] + print(f"\n{ST.bold}Connect to which server?{ST.reset}") + for i, entry in enumerate(servers, 1): + default = " (last)" if server_label(entry) == state.get("last") else "" + user = f" as {entry['username']}" if entry.get("username") else "" + print(f" {i}) {server_label(entry)}{user}{default}") + print(f" n) add a new server\n") + + hint = "" + if servers: + idx = next( + (i for i, e in enumerate(servers, 1) if server_label(e) == state.get("last")), + 1, + ) + hint = str(idx) + + while True: + choice = ask(f"server [1-{len(servers)}/n{', Enter=' + hint if hint else ''}]: ").strip().lower() + if not choice and hint: + entry = servers[int(hint) - 1] + return entry, False + if choice == "n": + entry = add_server(state) + return entry, True + if choice.isdigit() and 1 <= int(choice) <= len(servers): + return servers[int(choice) - 1], False + print(f"{ST.red}please enter 1-{len(servers)}, 'n', or Enter{ST.reset}") + + +def add_server(state: dict) -> dict: + """Ask for a new server address, probe it and save it.""" + while True: + raw = ask("server address (domain or ip:port): ").strip() + try: + host, port = parse_address(raw) + except ValueError as exc: + print(f"{ST.red}{exc} - use e.g. 192.168.1.10:8420 or code.my.lan{ST.reset}") + continue + entry = find_server(state, host, port) + if entry is None: + entry = {"host": host, "port": port, "username": "", "model": ""} + state["servers"].append(entry) + break + return entry + + +# --------------------------------------------------------------------------- +# daemon API +# --------------------------------------------------------------------------- + +def http_json(method: str, base: str, path: str, payload: dict | None = None, + timeout: int = REQUEST_TIMEOUT) -> tuple[int, dict]: + url = base + path + data = None if payload is None else json.dumps(payload).encode() + req = urllib.request.Request( + url, data=data, method=method, + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.status, json.loads(resp.read().decode()) + except urllib.error.HTTPError as exc: + try: + return exc.code, json.loads(exc.read().decode()) + except Exception: + return exc.code, {"error": f"HTTP {exc.code}"} + except (urllib.error.URLError, TimeoutError, ConnectionError, OSError) as exc: + return 0, {"error": f"cannot reach server: {exc}"} + + +def probe_server(entry: dict, probe: bool = True) -> str | None: + """Return an error string, or None if the daemon answers. + + With probe=False, skip the check (e.g. HTTP proxies that intercept + the port make a probe useless; login will fail with a clear error). + """ + if not probe: + return None + base = f"http://{entry['host']}:{entry['port']}" + status, body = http_json("GET", base, "/status", timeout=5) + if status != 200: + return str(body.get("error", f"no answer from {server_label(entry)}")) + return None + + +def api_login(entry: dict, username: str, password: str) -> tuple[int, dict]: + base = f"http://{entry['host']}:{entry['port']}" + return http_json("POST", base, "/login", + {"username": username, "password": password}, timeout=15) + + +def api_models(entry: dict, token: str) -> list[str]: + base = f"http://{entry['host']}:{entry['port']}" + status, body = http_json( + "GET", base, f"/models?token={urllib.parse.quote(token)}", timeout=15) + return list(body.get("models", [])) if status == 200 else [] + + +def api_chat(entry: dict, token: str, message: str, model: str) -> tuple[int, dict]: + base = f"http://{entry['host']}:{entry['port']}" + return http_json("POST", base, "/chat", + {"token": token, "message": message, "model": model}) + + +# --------------------------------------------------------------------------- +# login / model selection +# --------------------------------------------------------------------------- + +def login(entry: dict) -> str | None: + """Ask credentials, return the session token (or None).""" + default = entry.get("username") or "" + while True: + username = ask(f"username [{default}]: ").strip() or default + if not username: + print(f"{ST.red}username required{ST.reset}") + continue + if sys.stdin.isatty(): + try: + password = getpass.getpass("password: ") + except EOFError: + print() + sys.exit(1) + else: # piped stdin (scripted runs): plain input + password = ask("password: ") + status, body = api_login(entry, username, password) + if status == 200: + entry["username"] = username + left = body.get("remaining_minutes") + time_left = "unlimited" if left is None else f"{left:g} min left today" + print(f"{ST.green}logged in as {username}{ST.reset} " + f"{ST.dim}({time_left}){ST.reset}\n") + return str(body["token"]) + print(f"{ST.red}login failed: {body.get('error', 'unknown')}{ST.reset}") + if status == 403: # daily limit reached - no point retrying + return None + + +def pick_model(entry: dict, models: list[str]) -> str: + """Ask which model to use; preselects the saved one.""" + if not models: + print(f"{ST.yellow}server lists no models, using daemon default{ST.reset}") + return "" + if len(models) == 1: + return models[0] + + saved = entry.get("model") or "" + print(f"{ST.bold}Models on {server_label(entry)}:{ST.reset}") + for i, name in enumerate(models, 1): + mark = f" {ST.dim}(last used){ST.reset}" if name == saved else "" + print(f" {i}) {name}{mark}") + print() + + while True: + choice = ask(f"model [1-{len(models)}, Enter=" + f"{models.index(saved) + 1 if saved in models else 1}]: ").strip() + if not choice: + return saved if saved in models else models[0] + if choice in models: + return choice + if choice.isdigit() and 1 <= int(choice) <= len(models): + return models[int(choice) - 1] + print(f"{ST.red}pick 1-{len(models)} or a model name{ST.reset}") + + +# --------------------------------------------------------------------------- +# the agent look +# --------------------------------------------------------------------------- + +def print_agent_reply(text: str) -> None: + print(f"{ST.bold}Agent:{ST.reset}") + for line in text.rstrip().splitlines(): + stripped = line.strip() + if stripped.startswith("\u2713"): + print(f" {ST.green}\u2713{ST.reset} {stripped.lstrip('\u2713').strip()}") + elif stripped.lower() == "done.": + print(f" {ST.bold}{stripped}{ST.reset}") + else: + print(f" {line.strip() if stripped else ''}") + + +def build_prompt(history: list[tuple[str, str]], message: str) -> str: + """Plain-text transcript + new user message (server endpoint is stateless).""" + parts = [AGENT_PREAMBLE] + for user_text, answer in history[-HISTORY_TURNS:]: + parts.append(f"User: {user_text}\nAssistant: {answer}") + parts.append(f"User: {message}\nAssistant:") + return "\n\n".join(parts) + + +# --------------------------------------------------------------------------- +# chat REPL +# --------------------------------------------------------------------------- + +HELP = """ +commands: + /help show this help + /server show the connected server + /models list models on the server + /model switch model (also saved for this server) + /limit show remaining minutes today (last known) + /clear forget the conversation history + /quit exit (also Ctrl+D) +anything else is sent to the model. +""" + + +def chat_loop(entry: dict, token: str, model: str, remaining: float | None) -> None: + history: list[tuple[str, str]] = [] + while True: + try: + message = input(f"{ST.cyan}you>{ST.reset} ").strip() + except EOFError: + print() + return + except KeyboardInterrupt: + print() + continue + if not message: + continue + + if message.startswith("/"): + cmd, _, arg = message.partition(" ") + if cmd in ("/quit", "/exit", "/q"): + return + if cmd == "/help": + print(HELP.strip()) + elif cmd == "/server": + print(f"connected to {server_label(entry)} as {entry.get('username')}") + elif cmd == "/models": + models = api_models(entry, token) + print("\n".join(models) or f"{ST.red}no models listed{ST.reset}") + elif cmd == "/model": + if arg.strip(): + model = arg.strip() + entry["model"] = model + print(f"model set to {model}") + else: + print(f"current model: {model or '(daemon default)'}") + elif cmd == "/limit": + left = "unlimited" if remaining is None else f"{remaining:g} min" + print(f"time left today: {left}") + elif cmd == "/clear": + history.clear() + print("history cleared") + else: + print(f"unknown command {cmd} - try /help") + continue + + try: + status, body = api_chat(entry, token, build_prompt(history, message), model) + except KeyboardInterrupt: + print(f"\n{ST.yellow}request cancelled{ST.reset}") + continue + + if status != 200: + print(f"{ST.red}error: {body.get('error', 'unknown')}{ST.reset}") + if status == 403: + return # daily limit reached + continue + + answer = str(body.get("response", "")).strip() or "(empty reply)" + history.append((message, answer)) + print() + print_agent_reply(answer) + remaining = body.get("remaining_minutes") + if remaining is not None: + print(f"{ST.dim} ({remaining:g} min left today){ST.reset}") + print() + + +# --------------------------------------------------------------------------- +# main +# --------------------------------------------------------------------------- + +def main() -> None: + if os.name == "nt": + os.system("") # enable ANSI escapes in classic Windows consoles + + parser = argparse.ArgumentParser(description="sj coding agent client") + parser.add_argument("server", nargs="?", + help="server address (domain or ip:port), skips the picker") + parser.add_argument("-m", "--model", help="model to use, skips the picker") + parser.add_argument("--no-probe", action="store_true", + help="skip the reachability check before login (use behind " + "HTTP proxies that answer for every port)") + args = parser.parse_args() + + state = load_state() + + # -- choose server ------------------------------------------------------ + if args.server: + try: + host, port = parse_address(args.server) + except ValueError: + sys.exit(f"error: cannot parse address '{args.server}'") + entry = find_server(state, host, port) + created = entry is None + if entry is None: + entry = {"host": host, "port": port, "username": "", "model": ""} + state["servers"].append(entry) + else: + if not state["servers"]: + print(f"{ST.bold}First start - let's add a server.{ST.reset}") + entry = add_server(state) + created = True + else: + entry, created = pick_server(state) + + label = server_label(entry) + if created: + save_state(state) # persist immediately, before anything can go wrong + + print(f"\nconnecting to {label} ...") + if (err := probe_server(entry, probe=not args.no_probe)) is not None: + print(f"{ST.red}error: {err}{ST.reset}") + if created: + print(f"{ST.dim}'{label}' was saved - start coder again once it is reachable{ST.reset}") + else: + print(f"{ST.dim}saved under '{label}' - try again later{ST.reset}") + sys.exit(1) + + # -- login ---------------------------------------------------------------- + token = login(entry) + if token is None: + save_state(state) + sys.exit(1) + + # -- choose model --------------------------------------------------------- + models = api_models(entry, token) + model = args.model or pick_model(entry, models) + + if model: + entry["model"] = model + state["last"] = label + save_state(state) + + project = shorten_home(Path.cwd()) + banner(model or "(server default)", str(project), label) + chat_loop(entry, token, model, None) + print("bye") + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print("\nbye") diff --git a/coder b/coder new file mode 100755 index 0000000..44e9d5d --- /dev/null +++ b/coder @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# coder - launcher for the sjcode client. +# Usage: coder [server] [-m model] (see client.py --help) +exec python3 "$(dirname "$0")/client.py" "$@" diff --git a/server/config.toml.example b/server/config.toml.example new file mode 100644 index 0000000..f5c986b --- /dev/null +++ b/server/config.toml.example @@ -0,0 +1,24 @@ +# sjcode daemon configuration +# Copy this file to config.toml and adjust the values. + +[server] +# Port the daemon listens on (clients connect to this port). +port = 8420 +# Bind address. Keep 127.0.0.1 unless you want LAN access. +host = "127.0.0.1" + +[ollama] +# Where the daemon reaches the local ollama API. +base_url = "http://127.0.0.1:11434" +# Default model used when a client does not request a specific one. +model = "llama3.2" + +[database] +# SQLite file for user accounts. Relative to the project root. +path = "sjcode.db" + +[limits] +# How many minutes of AI usage each role gets per day. +# "user" is the default role assigned to new accounts. +user = 120 +admin = -1 diff --git a/server/server.py b/server/server.py new file mode 100644 index 0000000..c47193e --- /dev/null +++ b/server/server.py @@ -0,0 +1,590 @@ +#!/usr/bin/env python3 +"""sjcode daemon. + +Runs on the server, talks to the local ollama API (127.0.0.1) and serves +sjcode clients on its own port (from config.toml). + +On startup it drops into a minimal admin CLI; while that CLI runs, an +HTTP server thread serves the client API: + + POST /login {"username", "password"} -> {"token"} + POST /chat {"token", "message", "model"?} + -> {"response"} or {"error"} with 4xx + GET /models?token=... -> {"models": [...]} + +User accounts live in SQLite (sjcode.db) with: + username, password_hash, last_logged_in, role, used_today (minutes). + +Roles decide the daily usage limit (minutes) from config.toml [limits]. +""" + +from __future__ import annotations + +import cmd +import hashlib +import hmac +import json +import sqlite3 +import sys +import threading +import time +import tomllib +import urllib.error +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +CONFIG_FILE = ROOT / "config.toml" +DB_FILE = ROOT / "sjcode.db" # overwritten by config value once loaded + +PEPPER = b"sjcode-v1" +SESSION_TTL = 24 * 3600 # login token valid for one day + +CONF: dict = { + "server": {"host": "127.0.0.1", "port": 8420}, + "ollama": {"base_url": "http://127.0.0.1:11434", "model": "llama3.2"}, + "database": {"path": "sjcode.db"}, + "limits": {"user": 120, "admin": -1}, +} + + +def load_config() -> None: + """Read config.toml on top of the defaults.""" + global DB_FILE + if not CONFIG_FILE.exists(): + sys.exit( + f"error: {CONFIG_FILE.name} not found.\n" + f"Copy {ROOT / 'config.toml.example'} to {CONFIG_FILE} and edit it." + ) + try: + with open(CONFIG_FILE, "rb") as fh: + loaded = tomllib.load(fh) + except tomllib.TOMLDecodeError as exc: + sys.exit(f"error: invalid {CONFIG_FILE.name}: {exc}") + + for section, values in loaded.items(): + if isinstance(values, dict) and isinstance(CONF.get(section), dict): + CONF[section].update(values) + DB_FILE = ROOT / CONF["database"]["path"] + + +# -------------------------------------------------------------------------- +# database +# -------------------------------------------------------------------------- + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + used_today REAL NOT NULL DEFAULT 0, + last_logged_in TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + username TEXT NOT NULL, + created_at INTEGER NOT NULL +); +""" + + +def db() -> sqlite3.Connection: + conn = sqlite3.connect(DB_FILE, timeout=10) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + return conn + + +def init_db() -> None: + with db() as conn: + conn.executescript(SCHEMA) + + +def hash_password(password: str) -> str: + salt = f"{time.time_ns()}:{threading.get_ident()}".encode() + digest = hashlib.scrypt(password.encode(), salt=PEPPER + salt, n=2**14, r=8, p=1) + return f"{salt.decode()}${digest.hex()}" + + +def verify_password(password: str, stored: str) -> bool: + try: + salt_s, hexdigest = stored.split("$", 1) + except ValueError: + return False + calc = hashlib.scrypt( + password.encode(), salt=PEPPER + salt_s.encode(), n=2**14, r=8, p=1 + ) + return hmac.compare_digest(calc.hex(), hexdigest) + + +def new_token() -> str: + import secrets + + return secrets.token_hex(32) + + +def limit_for(role: str) -> float: + limits = CONF["limits"] + return float(limits.get(role, limits.get("user", 0))) + + +def ask_password(prompt: str) -> str: + """Hidden input on a terminal, plain stdin otherwise (scriptable).""" + if sys.stdin.isatty(): + import getpass + + return getpass.getpass(prompt) + print(prompt, end="", flush=True) + return input("") + + +def usage_reset_if_needed(conn: sqlite3.Connection, row: sqlite3.Row) -> None: + """used_today is a counter in minutes; reset it on a new calendar day (UTC).""" + last_date = row["last_logged_in"][:10] + if last_date and last_date != time.strftime("%Y-%m-%d", time.gmtime()): + conn.execute( + "UPDATE users SET used_today = 0 WHERE username = ?", (row["username"],) + ) + conn.commit() + + +def get_user(username: str): + with db() as conn: + row = conn.execute( + "SELECT * FROM users WHERE username = ?", (username,) + ).fetchone() + if row is not None: + usage_reset_if_needed(conn, row) + conn.commit() + return row + + +def remaining_minutes(username: str) -> float: + row = get_user(username) + if row is None: + return 0.0 + limit = limit_for(row["role"]) + if limit < 0: # unlimited + return float("inf") + return max(0.0, limit - float(row["used_today"])) + + +def add_used_minutes(username: str, minutes: float) -> None: + with db() as conn: + conn.execute( + "UPDATE users SET used_today = used_today + ? WHERE username = ?", + (minutes, username), + ) + + +def set_last_login(username: str) -> None: + stamp = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime()) + with db() as conn: + conn.execute( + "UPDATE users SET last_logged_in = ? WHERE username = ?", (stamp, username) + ) + + +def create_session(username: str) -> str: + token = new_token() + with db() as conn: + conn.execute( + "INSERT INTO sessions (token, username, created_at) VALUES (?, ?, ?)", + (token, username, int(time.time())), + ) + return token + + +def session_user(token: str): + """Return the username for a valid session token, else None.""" + with db() as conn: + row = conn.execute( + "SELECT username, created_at FROM sessions WHERE token = ?", (token,) + ).fetchone() + if row is None or int(time.time()) - row["created_at"] > SESSION_TTL: + return None + return row["username"] + + +# -------------------------------------------------------------------------- +# ollama +# -------------------------------------------------------------------------- + +def ollama_json(path: str, payload: dict | None = None, timeout: int = 300) -> tuple[int, dict]: + url = CONF["ollama"]["base_url"].rstrip("/") + path + data = None if payload is None else json.dumps(payload).encode() + req = urllib.request.Request( + url, data=data, headers={"Content-Type": "application/json"} + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.status, json.loads(resp.read().decode()) + except urllib.error.HTTPError as exc: + try: + body = json.loads(exc.read().decode()) + except Exception: + body = {"error": str(exc)} + return exc.code, body + except (urllib.error.URLError, TimeoutError, ConnectionError) as exc: + return 502, {"error": f"ollama not reachable: {exc}"} + + +def ollama_generate(prompt: str, model: str | None) -> tuple[int, str]: + status, body = ollama_json( + "/api/generate", + { + "model": model or CONF["ollama"]["model"], + "prompt": prompt, + "stream": False, + }, + ) + if status != 200: + return status, str(body.get("error", "ollama error")) + return 200, str(body.get("response", "")) + + +def list_models() -> tuple[int, list[str]]: + status, body = ollama_json("/api/tags", timeout=10) + if status != 200: + return status, [] + names = [m.get("name", "") for m in body.get("models", [])] + return 200, names + + +# -------------------------------------------------------------------------- +# HTTP API +# -------------------------------------------------------------------------- + +class ApiHandler(BaseHTTPRequestHandler): + server_version = "sjcode-daemon/0.1" + + # -- helpers ---------------------------------------------------------- + def _json(self, status: int, payload: dict) -> None: + body = json.dumps(payload).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _read_body(self) -> dict: + try: + length = int(self.headers.get("Content-Length", "0")) + return json.loads(self.rfile.read(length).decode()) + except (ValueError, json.JSONDecodeError): + return {} + + def log_message(self, fmt: str, *args) -> None: # quiet by default + return + + # -- routes ----------------------------------------------------------- + def do_GET(self) -> None: # noqa: N802 (http.server API) + if self.path.startswith("/models"): + status, models = list_models() + self._json(200 if status == 200 else 502, {"models": models}) + return + if self.path == "/status": + self._json(200, {"status": "ok", "daemon": "sjcode"}) + return + self._json(404, {"error": "not found"}) + + def do_POST(self) -> None: # noqa: N802 (http.server API) + data = self._read_body() + + if self.path == "/login": + self._handle_login(data) + return + if self.path == "/chat": + self._handle_chat(data) + return + self._json(404, {"error": "not found"}) + + # -- handlers --------------------------------------------------------- + def _handle_login(self, data: dict) -> None: + username = str(data.get("username", "")).strip() + password = str(data.get("password", "")) + if not username or not password: + self._json(400, {"error": "username and password required"}) + return + + row = get_user(username) + if row is None or not verify_password(password, row["password_hash"]): + self._json(401, {"error": "invalid credentials"}) + return + + limit = limit_for(row["role"]) + left = remaining_minutes(username) + if left <= 0: + self._json( + 403, + {"error": "daily limit reached", "limit_minutes": limit}, + ) + return + + set_last_login(username) + token = create_session(username) + self._json( + 200, + { + "token": token, + "role": row["role"], + "remaining_minutes": None if left == float("inf") else round(left, 1), + }, + ) + + def _handle_chat(self, data: dict) -> None: + token = str(data.get("token", "")) + username = session_user(token) + if username is None: + self._json(401, {"error": "not logged in"}) + return + + message = str(data.get("message", "")).strip() + if not message: + self._json(400, {"error": "empty message"}) + return + + left = remaining_minutes(username) + if left <= 0: + self._json(403, {"error": "daily limit reached"}) + return + + model = str(data.get("model") or "") or CONF["ollama"]["model"] + + started = time.monotonic() + status, answer = ollama_generate(message, model) + elapsed = time.monotonic() - started + + if status != 200: + self._json(status, {"error": answer}) + return + + used_min = elapsed / 60.0 + add_used_minutes(username, used_min) + left_now = remaining_minutes(username) + self._json( + 200, + { + "response": answer, + "model": model, + "used_minutes": round(used_min, 4), + "remaining_minutes": None if left_now == float("inf") else round(left_now, 2), + }, + ) + + +# -------------------------------------------------------------------------- +# admin CLI +# -------------------------------------------------------------------------- + +class DaemonCLI(cmd.Cmd): + """Minimal admin shell shown after daemon startup.""" + + intro = ( + "sjcode daemon CLI. Type 'help' for commands.\n" + "Accounts are stored in SQLite; the HTTP API runs in the background." + ) + prompt = "sjcode-admin> " + + def __init__(self, server_ref: dict) -> None: + super().__init__() + self.server_ref = server_ref + + # -- account management ---------------------------------------------- + def do_create(self, arg: str) -> None: + """create [role] - create an account (asks for password)""" + parts = arg.split() + if not parts: + print("usage: create [role]") + return + username = parts[0] + role = parts[1] if len(parts) > 1 else "user" + if get_user(username) is not None: + print(f"user '{username}' already exists") + return + pw1 = ask_password("password: ") + pw2 = ask_password("password (again): ") + if len(pw1) < 4: + print("password too short (min 4 chars)") + return + if pw1 != pw2: + print("passwords do not match") + return + with db() as conn: + conn.execute( + "INSERT INTO users (username, password_hash, role, created_at) " + "VALUES (?, ?, ?, ?)", + ( + username, + hash_password(pw1), + role, + time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime()), + ), + ) + print(f"created '{username}' with role '{role}' " + f"(limit: {limit_for(role)} min/day)") + + def do_list(self, arg: str) -> None: + """list - show all accounts""" + with db() as conn: + rows = conn.execute( + "SELECT username, role, used_today, last_logged_in " + "FROM users ORDER BY username" + ).fetchall() + if not rows: + print("no accounts yet. use 'create '") + return + print(f"{'username':<20} {'role':<10} {'used today':>10} last login") + for r in rows: + print( + f"{r['username']:<20} {r['role']:<10} " + f"{r['used_today']:>10.1f} {r['last_logged_in'] or '-'}" + ) + + def do_delete(self, arg: str) -> None: + """delete - remove an account""" + username = arg.strip().split()[0] if arg.strip() else "" + if not username: + print("usage: delete ") + return + with db() as conn: + cur = conn.execute("DELETE FROM users WHERE username = ?", (username,)) + if cur.rowcount: + print(f"deleted '{username}'") + else: + print(f"no user '{username}'") + + def do_role(self, arg: str) -> None: + """role - change a user's role""" + parts = arg.split() + if len(parts) != 2: + print("usage: role ") + return + username, role = parts + with db() as conn: + cur = conn.execute( + "UPDATE users SET role = ? WHERE username = ?", (role, username) + ) + if cur.rowcount: + print(f"'{username}' now has role '{role}' " + f"(limit: {limit_for(role)} min/day)") + else: + print(f"no user '{username}'") + + def do_passwd(self, arg: str) -> None: + """passwd - set a new password for a user""" + username = arg.strip().split()[0] if arg.strip() else "" + if not username: + print("usage: passwd ") + return + row = get_user(username) + if row is None: + print(f"no user '{username}'") + return + pw = ask_password("new password: ") + if len(pw) < 4: + print("password too short (min 4 chars)") + return + with db() as conn: + conn.execute( + "UPDATE users SET password_hash = ? WHERE username = ?", + (hash_password(pw), username), + ) + print(f"password updated for '{username}'") + + def do_usage(self, arg: str) -> None: + """usage [username] - show used minutes / limit for today""" + with db() as conn: + if arg.strip(): + rows = conn.execute( + "SELECT username, role, used_today FROM users " + "WHERE username = ?", + (arg.strip(),), + ).fetchall() + else: + rows = conn.execute( + "SELECT username, role, used_today FROM users " + "ORDER BY username" + ).fetchall() + if not rows: + print("no such user" if arg.strip() else "no accounts yet") + return + for r in rows: + limit = limit_for(r["role"]) + lim = "unlimited" if limit < 0 else f"{limit:g} min" + print(f"{r['username']:<20} {r['used_today']:>7.1f} min / {lim}") + + def do_reset(self, arg: str) -> None: + """reset [username] - reset the used_today counter""" + with db() as conn: + if arg.strip(): + conn.execute( + "UPDATE users SET used_today = 0 WHERE username = ?", + (arg.strip(),), + ) + else: + conn.execute("UPDATE users SET used_today = 0") + print("usage counter reset") + + # -- server control --------------------------------------------------- + def do_status(self, arg: str) -> None: + """status - show daemon settings""" + host, port = CONF["server"]["host"], CONF["server"]["port"] + print(f"listening on http://{host}:{port}") + print(f"ollama at {CONF['ollama']['base_url']} (model: {CONF['ollama']['model']})") + print(f"database at {DB_FILE}") + + def do_quit(self, arg: str) -> bool: + """quit - stop the daemon""" + print("shutting down...") + self.server_ref["stop"] = True + srv: ThreadingHTTPServer = self.server_ref["httpd"] + srv.shutdown() + return True + + def do_exit(self, arg: str) -> bool: + """exit - stop the daemon (same as quit)""" + return self.do_quit(arg) + + do_EOF = do_quit + + +# -------------------------------------------------------------------------- +# main +# -------------------------------------------------------------------------- + +def main() -> None: + load_config() + init_db() + + host = CONF["server"]["host"] + port = int(CONF["server"]["port"]) + + try: + httpd = ThreadingHTTPServer((host, port), ApiHandler) + except OSError as exc: + sys.exit(f"error: cannot listen on {host}:{port}: {exc}") + + ref = {"httpd": httpd, "stop": False} + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + + print(f"sjcode daemon listening on http://{host}:{port}") + print(f"ollama: {CONF['ollama']['base_url']} model: {CONF['ollama']['model']}") + print(f"database: {DB_FILE}") + + try: + DaemonCLI(ref).cmdloop() + except KeyboardInterrupt: + print() + finally: + httpd.shutdown() + httpd.server_close() + print("bye") + + +if __name__ == "__main__": + main() diff --git a/server/sjcode.db-shm b/server/sjcode.db-shm new file mode 100644 index 0000000000000000000000000000000000000000000000000000000000000000..9e5e40b49f64566d312e8d7d7194281321b787ca4ea838038f362a8adbc2805b GIT binary patch literal 32768 zcmeI)y$QlV7zW@={7Gw>CEP&7H7wmiYb$XGn}F_+T5tqgZ7dx@?}A8*G?s?vh2#Fh zXN$0*=`7D3C zJdV;G(!bNVJ&#i%K!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!8B61hOz}YOeI^Q658p009C7 z2oUHL$eTKIom&Q!@Io8EDZ1d=56K= z&tpHEKHDumobS5r-EiCp=Z-Fa^7-%k&*Xb|?qA#8rY75GdVPN1@pmqN`{SQ`XWHaM ze^=Ko3CCN-I+2=W|1BFnq|}_a;cmnYciZ&A(q-@lY>Y>>cY{pB?`t{T~Y{< zWFnDBC0ngDnwYDrzDTWXR3WJ3Y>hcu9jRm&3)!|*)ufqZC?@0&Pn=RhnML6jrQ9XW z0ioKpaf*jiB-_Sr8E)=+E^y;qyt(l8`9s58V5+~p>wnWf-fkEWfB*y_009U<00Izz z00bZafw2%6Ol?`3nd>DRYZ8XE4ACYTIc8^#(CsL1St=qGi`P}@S~&)`s^oTZHyqj_ z-Phtr5yHvkJ*$Js(RzxC6kfG6y)B+o$ilKqE>V=(q+*J(%4lDzQdasJiL^DPo%Ds` zY$&d+w{u}IF<{pG%7lf-hbQ&A!-{5jd9}3vZ2~Od%u{*taL+$flN1yx_F9 z-u!QEQ>TZY^<3cfXE#4MYHq!~txZn#-{|@u_gC5t0|F3$00bZa0SG_<0uX=z1Rwx` zt~=aYnwi*aXfQQeWwTMi$eI73