add fisr unaable version

This commit is contained in:
2026-09-12 00:35:26 +02:00
parent 5249c61cf9
commit 02d48a304a
8 changed files with 1228 additions and 1 deletions
+6
View File
@@ -1,3 +1,9 @@
# ---> sjcode
# Local daemon configuration and runtime data
config.toml
*.db
*.db-journal
# ---> Python # ---> Python
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
+86
View File
@@ -1,3 +1,89 @@
# sjcode # sjcode
ein terminal coding agent, der die ollama API nutzt 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 <name> [role]` | create account (asks for password, default role `user`) |
| `list` | show all accounts |
| `delete <name>` | remove account |
| `role <name> <role>` | change role |
| `passwd <name>` | 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 <n>` | 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`
Executable
+517
View File
@@ -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 <name> 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")
Executable
+4
View File
@@ -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" "$@"
+24
View File
@@ -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
+590
View File
@@ -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 <username> [role] - create an account (asks for password)"""
parts = arg.split()
if not parts:
print("usage: create <username> [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 <username>'")
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 <username> - remove an account"""
username = arg.strip().split()[0] if arg.strip() else ""
if not username:
print("usage: delete <username>")
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 <username> <role> - change a user's role"""
parts = arg.split()
if len(parts) != 2:
print("usage: role <username> <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 <username> - set a new password for a user"""
username = arg.strip().split()[0] if arg.strip() else ""
if not username:
print("usage: passwd <username>")
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()
Binary file not shown.
Binary file not shown.