# Changelog ## Node tools (included in v0.31.0) - `agent/Set-OllamaLoopback.cmd` binds Ollama on a Windows node to loopback in one step (TM-11, ADR-0072): double-click, it asks for administrator rights itself and starts despite the default execution policy. It detects whether Ollama is the agent's child or a user's desktop app, sets `OLLAMA_HOST=127.0.0.1:11434` there, restarts Ollama, verifies that every listener is a loopback address (else restores the previous state) and disables firewall rules that only opened Ollama. It refuses while other machines are connected to Ollama (`-Force ""`); `-Check` only reports. Router and agent versions are unchanged. ## Development tooling (pinning included in v0.26.1, the rest in v0.27.0) - Agent revocation and enrollment at the wire (TM-07): `test/selftest_agent_revocation.py` signs the tunnel challenge with an Ed25519 key like an agent and checks that revoking ends a waiting and a streaming request with the reason `revoked`, tells the agent, closes the connection and removes the node; that the revoked key is refused on reconnect; and that new keys beyond the per-address limit are refused with 1008 while an approved key still connects. - Route-inventory tests (TM-09): the session and memory self-tests enumerate every registered route from the router, so a route added later is covered automatically. No route answers without an identity (401 under `enforce`, never 2xx under `observe`, the memory backend is never reached), and every owner-scoped session-store call uses the authenticated caller. Fault injection (a fixed owner, a skipped identity check) makes both tests fail. - Monitoring: Prometheus v3.15.1 → v3.16.0 (#99, digest-pinned; the first image update proposed by Dependabot's docker-compose ecosystem). The stack's flags, scrape configuration and retention are unaffected by the 3.16 changes. - Third-party references are pinned (ADR-0055, TM-16): workflow actions to commit SHAs, `govulncheck` and `pip-audit` to versions, the monitoring images and the decision-service base images to digests (the running monitoring stack already used exactly these digests). `test/check_pinned_references.py` enforces it in CI; Dependabot also updates the image digests. `pip-audit` additionally audits the session-store, development, memory-test and rerank-server requirements. ## Development tooling (since v0.25.1, included in v0.25.2) - Run an independent pre-merge evidence check and restrict the Dependabot exemption to existing dependency versions (ADR-0050, accepted). - Weekly full security reviews resume from the previously reviewed chunks (ADR-0049, accepted). - Add the versioned threat model, risk register and repository security-review skill (ADR-0047). Every PR/update requires a change-bound review record; CI rejects missing/stale evidence and expired acceptances. Optional, read-only model reviews run after PR CI and weekly when explicitly configured; otherwise report skipped. Added negative security regressions. Router/agent versions and runtime configuration are unchanged. - The advisory recheck after PR CI compares against the PR's base commit from the event instead of the moving default-branch head; a PR merged before the recheck started showed a false "stale record" failure. Releases are tagged `vX.Y.Z` on this repository and carry the agent binaries as assets. Router and agent have their own version numbers; this table maps them. Details and the German summary are in each release's notes. | Tag | Date | Router | Agent | What changed | |---|---|---|---|---| | v0.31.1 (unreleased) | | 0.26.0 | 0.31.0 | **Further provider keys from the secret store** (router 0.26.0, ADR-0080): `render-env.sh` also renders the keys named in `EXTRA_SECRET_KEYS` in `render-env.conf` (upper-case names ending in `_KEY` or `_TOKEN` only, built-in keys are not rendered twice, a missing key is reported and skipped), and refuses any secret value with a line break, which would have started a further variable in the root unit's environment (TM-14, also for the built-in keys), so an OpenAI-compatible provider such as Mistral's EU endpoint needs no code change; `config.example.yaml` shows it disabled with its `egress_allow` entry, and `test/selftest_render_env.py` runs the renderer against a fake secret store in CI. **Router 0.25.1: decision models retrained on neutral training data** (ADR-0077): templates, generated data and the hand-written privacy test set use the standard German spelling and name nothing real; every filled sentence is grammatical, English templates take English values. The tf-idf model `router/decision-tfidf.json` (now `skirnir-tfidf-v2`) is retrained and records the digest of its data; over 50 template splits its top-1 is unchanged (0.907 before and after). `test/check_training_data.py` (CI) rebuilds both data sets, compares them with the committed files, checks the spelling and that the model was trained on them. **Privacy threshold out-of-fold** (ADR-0078, proposed): `train_privacy.py` chooses the threshold over five template folds and trains the head on all templates, default recall target 0.98 (the value of the head in use); over 20 splits test recall 0.898 → 0.964, worst split 0.76 → 0.96, precision unchanged. Training embeds one text at a time, as the service does. The embedding head and the privacy head live outside the repository: retrain them where the service runs (`decision-embed/README.md`); the router only needs the release. Repeated-split comparison `repeat_splits.py` (ADR-0079, proposed). | | [v0.31.0](https://github.com/psewar/skirnir/releases/tag/v0.31.0) | 2026-11-09 | 0.25.0 | 0.31.0 | **Deletion lifecycle** (TM-12, ADR-0076): completed deletions of sessions and memory operations are journaled (`deletions.jsonl` next to `config.yaml`, ids only) and applied again when the session store opens, before the memory outbox sends - a restored older database no longer brings them back or resends them; `POST /admin/memory/reconcile-deletions` deletes them again in a restored Hindsight. New `session_store.delete_memory_with_session` (default off): deleting a session, by its owner or by retention, also deletes the memory retained from its answers (memory operations now record their session; schema migration). A deletion requested while a send is in flight takes over when it ends. Responses name the operation an answer may be retained under (`X-Skirnir-Memory-Operation`); gauge `skirnir_memory_deletions_stuck`; the decision capture keeps at most 64 MiB plus one rotated file; posture infos `session_retention_off`, `memory_kept_on_session_delete`, `outbox_payload_retention_off`. **Python packages are hash-locked** (TM-16, ADR-0074): the router venv installs only what the distribution lacks, from `router/requirements-lock.txt` with `--require-hashes --no-deps` (`install.sh` and `deploy.py`, planned by `skirnir_router/pydeps.py`); `install.sh` adds `python3-paho-mqtt` and `python3-uvloop` from apt, and the router works with paho-mqtt 1.x and 2.x (`paho-mqtt>=1.6.1`). CI installs from `requirements-ci-lock.txt`; the security checks audit both locks and compare every hash with PyPI. **Release provenance** (ADR-0075): a workflow rebuilds the agent from the tag, and only if the bytes are identical attests their provenance and attaches CycloneDX SBOMs (`gh attestation verify`). **Breaking for secret-store access files:** `render-env.conf` moves to `/etc/skirnir-router-secrets/` (0700 root); `deploy.py` and `install.sh` move it, and the renderers no longer read the old place, which the router could write - a compromised router could otherwise have run its code as full root through the daily timer (TM-14, ADR-0073). The renderers also refuse an access or auth file that is not owned by root, writable by group or others, or reached through a router-writable path. The router unit sees `/etc/skirnir-router/certs` read-only and `/etc/skirnir-router-secrets` not at all; the secret and certificate helper units are hardened. **Agent 0.31.0** forwards only inference, listing and version paths to Ollama (`path_not_allowed_on_node` 403 for model management, blobs and anything else) and refuses an Ollama order older than the installed version unless `ollama_update.allow_downgrade: true`. | | [v0.30.0](https://github.com/psewar/skirnir/releases/tag/v0.30.0) | 2026-11-09 | 0.23.0 | 0.30.0 | **Guided first configuration** (ADR-0067): `router.py --init ` writes a valid, safe configuration from the new `router/config.minimal.yaml` - control password asked without echo and stored as a hash, one client token generated and shown once, owner-only file, then `--check` and `--posture`. `router.py --hash` without an argument asks for the password; `router.py --help` lists the commands. New posture findings: a well-known example token (high), a control port on the network without TLS (high), a control port on the network (info). Agent unchanged. **Agent keys and releases without the maintainer's tooling** (ADR-0068): `deploy/agent_keys.py --keys ` creates, rotates or replaces the signing key and signs an agent release by hand (`sign-release --version `: manifest and release-named files to copy to `/etc/skirnir-router/agent/`); it needs only Python and `cryptography`, no SSH. **Install script** (ADR-0069): `sudo bash router/install.sh` installs or upgrades the router on Debian/Ubuntu - checks root, distribution and Python first, installs missing packages, the program files, a venv with the full requirements (`paho-mqtt<2` also where the distribution ships 2.x) and the units, then checks an existing configuration like the service reads it; `--check` only reports. `.cmd` files keep CRLF line endings. **Agent 0.30.0: Linux setup and a private router CA** (ADR-0070): `sudo skirnir-agent setup [--router URL] [--update-key KEY] [--ca-file PEM]` installs a Linux node in one step (configuration with key and log folders under /var, folders, binary, systemd unit, start); `router.ca_file` trusts a private CA for the tunnel and update downloads in addition to the system store, and `check-config` refuses an unusable file. The agent's help text is English and lists `game`. **New documentation** (ADR-0071): README (EN/DE) with a quick start - try without GPU, install router, add a GPU node, approve, first request, Home Assistant - and `docs/` topics in English and German (install-router, install-agent, configuration, clients, operations, troubleshooting, reference); outdated statements corrected. `router.py --init` and `config.minimal.yaml` use `/etc/skirnir-router/certs/`, the path `skirnir-router-cert.path` watches. | | [v0.29.0](https://github.com/psewar/skirnir/releases/tag/v0.29.0) | 2026-11-09 | 0.22.2 | 0.29.1 | **Quiet a single node for a measurement** (ADR-0061). `POST /admin/nodes/{fp}/quiet` with `until` (epoch seconds, at most 12 h ahead) and `reason`: until then the router unloads nothing on this node (busy safety net and busy transition) and skips prewarm and the residency rule, each with a log line naming the reason. Busy detection stays on, so real traffic keeps away. The quiet ends by itself when `until` passes, or with `DELETE /admin/nodes/{fp}/quiet`; that refuses with 409 and `would_unload` while the node is busy and the safety net would then unload models, unless the body says `accept_unload: true`. A free node is prewarmed once at the end. `/admin/state` shows `quiet` per node, the web UI a badge, the journal `quiet` and `quiet_end`. In memory only: a router restart ends it. Replaces switching `unload_on_busy` and `prewarm` globally for a test bench. Agent unchanged. **Privacy check reads recalled facts** (TM-05): the content check before a cloud tier (`privacy_check: true`) now also reads the facts the router inserted from memory as text; the inserted data message escapes non-ASCII characters, so a configured term such as a name with an umlaut in a recalled fact did not keep the request local. **Unexpected errors are logged without their message** (ADR-0062, TM-15): a last-resort middleware logs type, path, a reference and code locations and answers a fixed 500 with the reference, so an exception text carrying data reaches neither the log nor the client. New self-tests: final cloud payload with memory (TM-05), gateway routes with the gateway on, token rotation and grant revocation (TM-09), canaries on memory, session and upstream error paths (TM-15). Agent unchanged. **Per-client bounds** (ADR-0063, TM-08): a client may have at most `max_concurrent` requests in progress (default 4, next one 429 with `Retry-After: 1`) and send a declared body of at most `max_body_mib` (default 16, else 413); both are set per client in the configuration or the UI, and `skirnir_client_peak_inflight` shows the measured peak. A client that runs more in parallel needs a higher `max_concurrent` before the upgrade. **Security posture as facts** (ADR-0064, TM-01, TM-11): `router.py --posture ` and the start log report observe mode, an unlocked policy, shared tokens, address-only clients, a control port without login, an API port without TLS, static nodes over plain HTTP and a session store without verified TLS, each with the next step; no secret is printed. The agent example configuration binds a child Ollama to loopback (it listened on all addresses, reachable around the router). **Control UI hardened** (ADR-0065, TM-03): a value inside an inline handler is passed as a JSON string literal (`js()`), and `esc()` escapes the single quote; before, a model name reported by a node could run script in the operator's browser. The control port sends a Content-Security-Policy (`connect-src 'self'`, `frame-ancestors 'none'`, no foreign code), `X-Frame-Options: DENY`, and both ports `nosniff` and no referrer; no CORS header. `test/dev_env.py` works again (ADR-0053 switch). **Lost signing key has a defined recovery** (ADR-0066, TM-13): `deploy.py --agent` no longer creates a signing key when none is found (a lost key silently became a new one that every node refused); `--create-agent-key` makes the first one, `--replace-lost-agent-key ""` a recorded replacement. The node step is in the repository: `agent/Set-UpdateKey.cmd` (Add, Complete, Replace, Check; Windows PowerShell 5.1). Approving a node: compare its fingerprint with `skirnir-agent identity` (TM-07). | | [v0.28.0](https://github.com/psewar/skirnir/releases/tag/v0.28.0) | 2026-11-09 | 0.20.0 | 0.29.1 | **Breaking for configurations with shared-memory writers:** a shared scope names its writers (`memory_scopes..writers`, ADR-0060, TM-10); a write grant of an unlisted client stops loading with a message naming client and scope, absent or empty `writers` means nobody may write, and the list is checked again per request. **No automatic recall into requests with tools** (ADR-0059, TM-10): a request carrying a nonempty `tools` list gets `skipped:tools` instead of recalled facts, unless the project grant sets `recall_into_tools: true`. Agent unchanged. | | [v0.27.1](https://github.com/psewar/skirnir/releases/tag/v0.27.1) | 2026-11-09 | 0.19.1 | 0.29.1 | **First release signed with the rotated update key** (ADR-0058, TM-13). Agent 0.29.1 is the same code as 0.29.0; only the version string differs. It carried the first manifest signed with the new key in a practised rotation: every node accepted it before the old key was removed. README (EN/DE): let every node install a release signed with the new key before removing the old public key. Router unchanged. | | [v0.27.0](https://github.com/psewar/skirnir/releases/tag/v0.27.0) | 2026-11-09 | 0.19.1 | 0.29.0 | **The agent signing key can be rotated** (ADR-0058, TM-13): `update.public_keys` lists further trusted operator keys next to `update.public_key`; a manifest is valid if one of them verifies it, and every entry must be a valid key (a typo is reported with its position). `deploy.py --new-agent-key` prepares the next signing key and prints its public part, `deploy.py --use-next-agent-key` switches to it and keeps the old key as retired; both refuse without their precondition. Router unchanged. | | [v0.26.1](https://github.com/psewar/skirnir/releases/tag/v0.26.1) | 2026-11-08 | 0.19.1 | 0.28.0 | **Router files are owner-only** (ADR-0056, TM-15): the audit log, decision journal, node registry, UI overrides (client token hashes) and fallback observations are created with mode 0600, and an existing file is tightened to 0600 on its next write; the systemd unit sets `UMask=0077`. A separate reader of the audit log needs the service user. A new CI self-test drives the API and control apps with canary tokens, passwords and prompts and fails if any of them reaches the log, audit log, journal or overrides file. **Bounded pre-authentication state** (ADR-0057, TM-08): the router tracks at most 1024 unauthenticated sources (least recently seen evicted, `unauthenticated_evicted` in the admin view) and writes one `auth_denied`/`auth_missing` audit line per source and event kind every 300 s, at most 120 a minute in total; each line carries `suppressed` and `suppressed_all` counts. Under `enforce` the audit log therefore no longer has one line per refused request; per-source totals still count every request. Agent unchanged. | | [v0.26.0](https://github.com/psewar/skirnir/releases/tag/v0.26.0) | 2026-11-08 | 0.19.0 | 0.28.0 | **Breaking for agent configurations:** agent 0.28.0 refuses a configuration with a top-level key it does not read (a misspelt block, or one retired in an earlier version) and names the key with the known ones; before, such a block was silently ignored. `setup` no longer renames the secret-store block from its old block name; such a block is refused like any unknown key, so rename it to `secret_store:` before upgrading. A self-update refuses an affected configuration in its `check-config` step and the old binary keeps running (ADR-0051, proposed). Unknown nested keys are still ignored. **Neutral texts:** budget texts in log lines, Home Assistant problems, the settings and the web UI no longer name a currency (budgets are in the currency of the catalogue prices; keys, fields, metrics and the unit of the Home Assistant cost sensor keep their names). German documentation, comments and UI texts use the standard spelling; examples and tests use documentation addresses, neutral places and time zones and generic hardware descriptions. The privacy dataset builder's template lists changed; its committed data and the trained head stay as they are until they are regenerated. **Concrete model names need an explicit grant** (ADR-0052, supersedes ADR-0046, closes SK-SEC-05 / TM-04). Breaking: a client without `models` in `client_auth.clients` used to be allowed concrete model names; now it gets roles only (403 "concrete model names not allowed (roles only)"). The router names every such client in a warning when it loads the configuration; add `models: true` where concrete names are needed. Only the boolean `true` counts; another value is a configuration error. Clients with `inference: false` and the router's own UI client are unaffected. **The control port requires users even on loopback** (ADR-0053, TM-02): without `control_auth.users` it answers 503 also to loopback peers, so a local reverse proxy can no longer expose the UI without credentials. Breaking for password-free development setups: set `control_auth.allow_unauthenticated_loopback: true` with a literal loopback listener. **Agent 0.28.0 also refuses signed downgrades** (ADR-0054, TM-13): a validly signed manifest older than the running release is refused with update kind `downgrade`; a deliberate rollback needs `update.allow_downgrade: true` in the node's configuration. **Monitoring:** Prometheus takes its 90-day retention from `prometheus.yml` (`storage.tsdb.retention.time`) instead of the deprecated `--storage.tsdb.retention.time` flag; the effective retention is unchanged. Needs Prometheus 3.11 or later (the stack ships v3.15.1). | | [v0.25.2](https://github.com/psewar/skirnir/releases/tag/v0.25.2) | 2026-11-08 | 0.18.2 | 0.27.0 | Cloud providers require final HTTPS URLs, except explicit literal loopback HTTP (ADR-0048). Migrate remote HTTP providers and redirecting URLs before upgrading. **Monitoring stack:** Prometheus v3.5.0 → v3.15.1 and Grafana 12.2.0 → 13.3.0 in `monitoring/compose.yaml`. Grafana 13 moves dashboards and folders into its unified storage on the first start; there is no downgrade path other than restoring a copy of `grafana-data` taken while Grafana was stopped, so take one before `deploy_ct.py`. The health checks, data source provisioning and the dashboard are unchanged. | | [v0.25.1](https://github.com/psewar/skirnir/releases/tag/v0.25.1) | 2026-11-02 | 0.18.1 | 0.27.0 | **The hardened systemd unit starts.** With all capabilities dropped (ADR-0044) root gets "permission denied" on the inaccessible `/root` of `ProtectHome=true` instead of "not found", and asyncpg's lookup of `~/.postgresql` client certificates aborted the session store start; the unit now uses `ProtectHome=tmpfs`, which hides the home directories just as well. A TLS certificate or key the service cannot read (for example owned by another user with mode 600) now stops the start with a plain message instead of a traceback, and `deploy.py` checks the configured files without capabilities (`router.py --check --files` under `setpriv`) before it restarts anything. A failed session store start logs the error class and errno (no message text, it may contain the DSN). | | [v0.25.0](https://github.com/psewar/skirnir/releases/tag/v0.25.0) | 2026-11-01 | 0.18.0 | 0.27.0 | **Security hardening after a review** (`docs/security-review-2026-11-01.md`, ADR-0040 to ADR-0046). Breaking for admin scripts: every non-GET request to `/admin/*` needs the header `X-Skirnir-UI: 1` (the web UI sends it), and requests marked `Sec-Fetch-Site: cross-site` are refused (CSRF). Authentication fails closed: `enforce` with an empty client list refuses inference, a control port without users serves only on loopback, and `control_listen` defaults to `127.0.0.1:11435` (set it explicitly for remote agents). Agent enrollment is bounded (32 pending keys, 6 new keys per IP in 5 min, 32 KiB hello, 2 MiB tunnel messages). A data class configured for a client is a floor its requests cannot lower; unconfigured clients still declare their own. systemd units hardened (root kept for now), example password hashes replaced, Go MQTT client and `golang.org/x/net` updated, `govulncheck`, `pip-audit` and Dependabot in CI. Access to concrete model names stays allowed by default (SK-SEC-05 open, ADR-0046). | | [v0.24.2](https://github.com/psewar/skirnir/releases/tag/v0.24.2) | 2026-10-28 | 0.17.1 | 0.26.1 | **An interrupted transaction start no longer hands the connection back with an open transaction.** The ordered shutdown (v0.14.4) removed one cause of "Resetting connection with an active transaction"; two remained: aiohttp 3.8 cancels a handler whose client disconnected, and the session store's own timeout (`session_store.timeout_s`) can fire while `BEGIN` is on the way. asyncpg keeps such an interrupted start as the connection's transaction, and the pool reported it when the connection came back. The store now drops that connection instead: the server rolls back what `BEGIN` opened, and the pool replaces the connection on its next use. The interrupted call also ends at once; before, it waited for the server's answer to the `BEGIN` (up to the driver's command timeout), so the store's timeout did not bound it. Agent unchanged. | | [v0.24.1](https://github.com/psewar/skirnir/releases/tag/v0.24.1) | 2026-10-24 | 0.17.0 | 0.26.1 | **The hold remembers the server's models.** Outside its window the router holds the server as soon as it answers, seconds after the start and before the agent's 30-s sampler saw its models. 0.26.0 then remembered the need but not the models, and every restart would have started the server again to learn. The hold now takes the models as well. Router unchanged. | | [v0.24.0](https://github.com/psewar/skirnir/releases/tag/v0.24.0) | 2026-10-24 | 0.17.0 | 0.26.0 | **The agent starts an OpenAI-compatible server only when the router wants it, and only with room in RAM.** After every restart the agent started the server (Strata: about 38 GB into RAM) although the router keeps it held outside its window; one such start at 98 % RAM was followed by a bluescreen. Agent 0.26.0 remembers the server's models and its RAM need (peak private bytes in the last run) in `/upstream.json` and, with a standby, starts with the server held; without the file the server starts once and the agent learns both. Before every start it checks RAM in use plus that need against `tunnel.upstream_fallback.max_ram_percent` (default 90, 0 = off) and otherwise keeps the server held and reports `upstream_blocked` until there is room. Router 0.17.0 reads the fact: no release candidate while it stands (`memory_pressure` with the numbers instead of a 180-s wait), no release from prewarm or the window, shown in `/admin/state`. | | [v0.23.5](https://github.com/psewar/skirnir/releases/tag/v0.23.5) | 2026-10-24 | 0.16.5 | 0.25.0 | **A skipped automatic recall says why, and the journal keeps every request's memory state.** The automatic recall of an inference asks with the last message's text. When the last message was not the user's, or its content was not a nonempty string, the router silently sent no recall: the response carried no memory state, nothing was counted, and the journal could not tell this apart from a filtered query or an unreachable memory (a home-automation log triage got no facts and the cause stayed open). Such a request now reports `memory_state: skipped:` (`no_messages`, `last_not_user`, `content_not_text`) in `X-Skirnir-Memory-State` and the `routing` block, is counted as `outcome="skipped_"`, and proceeds as before. An empty user message used to answer 400 and is skipped as well. The journal's `route` entry (`events.jsonl`) carries `memory_state` and `memory_facts` for every request that used memory. Agent unchanged. | | [v0.23.4](https://github.com/psewar/skirnir/releases/tag/v0.23.4) | 2026-10-24 | 0.16.4 | 0.25.0 | **Switching to the standby no longer breaks off the server's answers.** The hold stops the OpenAI-compatible server at once; a request for a standby model held it while another request was streaming from it (observed: an overnight agent's stream cut after 5.8 s by a memory write's request). The switching request now waits until no answer runs on the server (`Node.server_inflight`, requests waiting to switch do not count), at most for its priority's admission budget, and `ensure_standby` checks again right before the hold. Without a gap: 503 with the new reason code `server_busy` (`Retry-After` 30 s). Agent unchanged. | | [v0.23.3](https://github.com/psewar/skirnir/releases/tag/v0.23.3) | 2026-10-24 | 0.16.3 | 0.25.0 | **Releasing the held server checks no VRAM budget.** With the whole card as budget (v0.23.2) the request was still refused, now with `gpu_busy`: the catalog's planning figure for the server (28.8 GiB) exceeded the card minus reserve, desktop baseline and a small foreign share (28.5 GiB). The server sizes itself to the card (its own reserve) as it did before the hold, so a request for its model while the standby serves is a candidate unless the node is busy (game) or short of memory. **The night window releases a server the agent reports held**, not only one the router remembers holding: after a router restart that memory was empty, and the window never released the server. Agent unchanged. | | [v0.23.2](https://github.com/psewar/skirnir/releases/tag/v0.23.2) | 2026-10-24 | 0.16.2 | 0.25.0 | **The held server's model gets the whole card.** While the standby Ollama served with a large model loaded, a request for the held server's model was refused with `vram_full`: the router compared the server's need with what was left next to the standby's models. The release stops the standby with all its models before the server starts, so the request now has the budget of a switch (card minus reserve, desktop baseline and foreign memory), in the candidate check and in the reason codes. Agent unchanged. | | [v0.23.1](https://github.com/psewar/skirnir/releases/tag/v0.23.1) | 2026-10-23 | 0.16.1 | 0.25.0 | **The standby is prewarmed after a window hold.** A hold outside the window left the standby Ollama empty: prewarm runs when a node comes online or free, and residency only when something is loaded, so the first request of the day loaded its model cold. The window check now prewarms the standby once per hold, when the node is free and nothing is loaded or loading. Agent unchanged. | | [v0.23.0](https://github.com/psewar/skirnir/releases/tag/v0.23.0) | 2026-10-23 | 0.16.0 | 0.25.0 | **An OpenAI-compatible server only in a window or on request** (`modes.upstream_window`, off by default, settings group "OpenAI-Server"). With the window on, the router holds a server idle for `idle_hold_s` outside the window (for example 01:00–07:00), and the standby Ollama serves and is prewarmed during the day. A request for the server's model still starts it. Prewarm, residency and the end of a game no longer bring the server back outside the window. Inside the window a held server is released once the node is free and the standby is idle. Agent unchanged. | | [v0.22.0](https://github.com/psewar/skirnir/releases/tag/v0.22.0) | 2026-10-23 | 0.15.0 | 0.25.0 | **Switch an OpenAI-compatible node to its standby Ollama for a request.** While an OpenAI-compatible server such as Strata held the card, the node offered only that server's model: other models showed up in the UI from the catalog, but no request for them found a node. The agent now reports the standby Ollama's models from its model store, also while it is stopped (heartbeat `standby_models`). A request for one of them, as a concrete model or as a role tier, may hold the server; the node is a cold candidate with the new penalty `modes.score.switch` (default 60). The request waits until the standby serves (up to three minutes). The server comes back with the next request for its model or through the residency check. The UI marks models reachable only by switching with ↻ and models no node can serve with ✕. | | [v0.21.0](https://github.com/psewar/skirnir/releases/tag/v0.21.0) | 2026-10-23 | 0.14.0 | 0.24.0 | **Sleep and wake-up as a fact.** The Windows agent service accepts power events and reports suspend and resume to the router at once (new tunnel frame POWER; older routers ignore it). Before, the router only saw a ping timeout a minute after the machine slept, and short standby wake-ups looked like a flapping tunnel. The router takes a sleeping node out of routing right away and keeps `power`/`power_since` in `/admin/state` and the journal (event `power`). A heartbeat counts as awake again if the resume notice was lost. The UI shows "asleep". Home Assistant stays optional: with MQTT configured there is a new `binary_sensor.skirnir_node__asleep`; without it nothing else changes. | | [v0.20.1](https://github.com/psewar/skirnir/releases/tag/v0.20.1) | 2026-10-23 | 0.13.1 | 0.23.0 | **Hindsight health is probed, not inferred.** The service status asks Hindsight's `/health` on every maintenance pass (10 s) and at startup. Before, it showed `hindsight: unknown` after a router start until a client happened to use the memory (observed: five hours). The probe touches no bank and no content. | | [v0.20.0](https://github.com/psewar/skirnir/releases/tag/v0.20.0) | 2026-10-23 | 0.13.0 | 0.23.0 | **Content check before a cloud tier (opt-in per client).** Until now a client whose data class is `personal` (the default when it declares nothing) never reached a cloud tier. With `clients..privacy_check: true` and `router.cloud.privacy_check.enabled: true` the cloud tier stays a candidate for such a client, and when it is chosen (no local node can serve) the request is checked: rules on what the user wrote (operator terms such as family names, e-mail, phone, IBAN, AHV, street address, keyword groups for health, finances, credentials, relationships, law), then a classifier on the router host (decision-embed `POST /privacy`: e5-small plus a trained head, 2-5 ms; hand-written test set 50/50 personal found, 1/50 neutral flagged). A rule hit, a probability above the threshold or a classifier that does not answer keeps the request local (fail closed), and it gets the configured `refusal` sentence as its answer instead of an error. An allowed request leaves with its tool results redacted: Home Assistant live-context entries of configured domains (person, device_tracker, todo, lock, camera, ...), device classes (motion, presence, door, window, ...) and names, and the whole result of configured tools (`todo_get_items`). A declared `routing.data_class` is never overruled. Journal event `privacy_check` (outcome, rule or probability, redacted count; never the text); `/admin/state` shows the settings without the operator's terms. New selftest `selftest_privacy_check.py` in CI. Agent unchanged. | | [v0.19.0](https://github.com/psewar/skirnir/releases/tag/v0.19.0) | 2026-10-23 | 0.12.0 | 0.23.0 | **Standby Ollama while an OpenAI-compatible server is held.** Holding the server for a game (v0.18.0) left the node with nothing to serve, not even the small `busy_ok` model of a voice assistant. New agent setting `tunnel.upstream_fallback` (`child`: an enabled Ollama child, `upstream`: its loopback URL, default `http://127.0.0.1:11434`): the agent keeps that child stopped while the server runs, starts it once the server has stopped for a hold, and sends the tunnel's requests to it. The OLLAMA frame then carries `serving: "ollama"` with Ollama's models plus the held server's (digest `openai:`), and the agent fact `upstream_fallback` tells the router. Router 0.12.0 treats such a node as an Ollama node while the standby serves (`busy_ok` models during the game, Ollama unload for the rest, `/api/show` for the standby's models); only a request for the server's model goes the OpenAI way and releases the server, and prewarm releases it instead of loading into the standby. On release the agent reports first, lets answers on the standby finish (at most two minutes), stops it, and only then starts the server, so the two never share the card. The standby is left out of the router's Ollama update. `/admin/state` shows `upstream.fallback` and `upstream.serving`; the Home Assistant device shows the standby's models and memory while it serves. New selftest `selftest_openai_node_standby.py` in CI. | | [v0.18.0](https://github.com/psewar/skirnir/releases/tag/v0.18.0) | 2026-10-23 | 0.11.0 | 0.22.0 | **OpenAI-compatible nodes, part 2.** Freeing the GPU for a game: with `tunnel.upstream_child` the router stops the node's OpenAI-compatible server when the node turns busy (`{"t":"upstream-hold"}`) and starts it again when the node is free or a request needs the model (`upstream-release`; the request waits until the model is loaded, up to 180 s). The agent reports the server as held / starting (OLLAMA `upstream_state`) instead of unreachable. Its Home Assistant device takes loaded models and GPU memory from that report instead of `/api/ps`. Managed session turns and embeddings (`/v1/embeddings`; a server without it is skipped for them) now work on such a node. | | [v0.17.0](https://github.com/psewar/skirnir/releases/tag/v0.17.0) | 2026-10-23 | 0.10.0 | 0.21.0 | **OpenAI-compatible inference server as a node backend** (e.g. Strata, a large mixture-of-experts model on one GPU plus system RAM). Agent setting `tunnel.upstream_api: openai` (default `ollama`): the agent reports the server's models from `/v1/models` and says so in its facts; the router sends chat and generate as `/v1/chat/completions` through the tunnel, translated like a cloud request, and skips what only Ollama has (`/api/show`, prewarm, busy unload, version). The server's `reasoning_content` arrives as Ollama's `thinking`, and `think: false` becomes `reasoning_effort: "none"` (a thinking model otherwise spent the whole token budget on thinking). Embeddings and managed session turns stay on Ollama nodes. Also fixes `TunnelResponse` lacking `iter_any`/`iter_chunked`, which the SSE reader, `managed.lines` and the managed unary path call on a tunnel response. | | [v0.16.2](https://github.com/psewar/skirnir/releases/tag/v0.16.2) | 2026-10-22 | 0.9.2 | 0.20.1 | **Slowness is reported as a fact.** A configuration change dropped the KV cache quantisation; the large model no longer fit the card, Ollama placed 3.2 GiB of it in system RAM, and every request ran at 8.8 instead of about 100 tok/s. Nothing failed, so it showed only as slow answers, for a day. The router already received the sizes from `/api/ps` (agent push or poll) and now keeps `size - size_vram` per loaded model as `ram_spill` on the node (`/admin/state`), in the Home Assistant view and as the new `binary_sensor` **Model partly in RAM** per node; from `router.ram_spill_problem_gib` (default 0.5 GiB, 0 = off, settings UI) it is also a **Problem**. New sensors per node carry the generation and prompt rate (tok/s) of the last warm request, with the model as attribute, as raw values for a baseline in Home Assistant; a reply under 20 tokens, a prompt under 200 new tokens and benchmark runs do not count. New selftest `selftest_slowness_signals.py` in CI. Agent unchanged. | | [v0.16.1](https://github.com/psewar/skirnir/releases/tag/v0.16.1) | 2026-10-22 | 0.9.1 | 0.20.1 | **English code identifiers and messages** (second step of design/english-names.md; no interface changes). Log lines, API and validation errors, `router.py --check` (`deploy.py` now looks for `configuration ok`) and the agent's command-line output and setup questions are English; Go files `spiel*.go` → `game*.go` and `kuerzung*.go` → `truncation*.go`; German Go and Python identifiers renamed. Texts that reach the operator in German stay German: Home Assistant problem texts and display names (including GPU guard reasons and update errors), the settings UI labels, the `rules` keywords and the German prompts. | | [v0.16.0](https://github.com/psewar/skirnir/releases/tag/v0.16.0) | 2026-10-22 | 0.9.0 | 0.20.0 | **Every interface uses English names** (migration plan: design/english-names.md). Home Assistant entities get English entity IDs through `default_entity_id` (before, Home Assistant derived them from the German display names, e.g. `sensor.skirnir_knoten_online` → `sensor.skirnir_nodes_online`); display names stay German, unique IDs and discovery topics stay as they are so existing entities keep their history. Existing installations keep their old entity IDs until the entity registry is renamed (see the plan). Renamed: the truncation event `kontext_gekuerzt` → `context_truncated` with English fields, the MQTT topic `kuerzung` → `truncation`, the agent truncation types `eingabe_gekuerzt`/`kontext_voll` → `input_truncated`/`context_full`, GPU guard states, events, fields and attributes (`gedrosselt` → `throttled`, `grund` → `reason`, …), journal reasons (`spiel-leicht` → `game_light`, `ausweichen` → `fallback`, …), measurement steps, catalog `source` values, the metric label `skirnir_node_gpu_guard{state}`, `anfragen.jsonl` → `requests.jsonl` (taken over on start), modules `kontextpruefung`/`ausweich_beobachtung` → `context_check`/`fallback_observation`, agent command `spiel` → `game`. Compatibility: the router translates GPU guard reports of agents before 0.20.0 and old entries in `events.jsonl` on load; the UI shows the English values in German in its German view. Fix: the catalog showed models measured with nvidia-smi as estimated. | | [v0.15.6](https://github.com/psewar/skirnir/releases/tag/v0.15.6) | 2026-10-22 | 0.8.13 | 0.19.1 | **Curating a shared memory scope.** Deleting a memory operation was owner-only, so nobody could remove a wrong or outdated fact that another client had written into a shared scope. A client with both `read` and `delete` on a project bound to a shared `scope` is now its curator: new `POST /v1/skirnir/memory/list` returns one page (1-50) of the scope's memory units with the operation each came from (`document_id`), `source_memory_ids` and provenance (units the content filter rejects are listed under `withheld` by id only), and the operation status and delete endpoints accept any operation in that scope's bank, also other clients'. Never cancel or retry, never a private bank, and every other client still gets 404 for a foreign operation. Each curated deletion is logged with curator, owner and scope and counted as `outcome="curated"`; the deletion then runs under the curator's grant. A correction is a new fact plus the deletion of the old operation. Agent unchanged. | | [v0.15.5](https://github.com/psewar/skirnir/releases/tag/v0.15.5) | 2026-10-22 | 0.8.12 | 0.19.1 | **A blocked agent update is recognised by its error code, not its text.** When Smart App Control or WDAC blocks the new agent binary, the router retries after 30 minutes instead of six hours. It decided this by searching the agent's failure message for "application control" or "Anwendungssteuerung", localized Windows text. Agent 0.19.1 recognises the block by the Windows error code that starting the binary returns (4551, 4556-4559, 4580-4583) and reports it as the field `update.kind = "app_control"` (always sent, empty otherwise). The router decides on `kind`; the text match remains only for agents before 0.19.1, which send no `kind`, and can be removed once every node runs 0.19.1 or later. | | [v0.15.4](https://github.com/psewar/skirnir/releases/tag/v0.15.4) | 2026-10-22 | 0.8.11 | 0.19.0 | **Explicit control flow complete (parts 3 and 4).** No router module uses an exception as a control signal any more; `test/control_flow_baseline.json` is empty, so every file must stay at zero findings. Visible changes: an admin config change with a tier missing `num_ctx` or a model missing `weights_gib` answers 400 naming the field instead of 500; a refused agent sign-in logs its reason (wrong signature, not JSON, timeout); a failed write of `nodes.json` no longer reaches the request and is retried; benchmark and measurement steps report their own error; an invalid fixed `ollama_tls_fingerprint` is refused when the configuration loads instead of stopping the router in the node constructor; a decision engine that is unreachable or returns unusable data hands over to the next engine of the chain as a value (`EngineError` removed), and a jevlike answer that is not JSON no longer escapes the chain. Fix: a request waiting on an `Idempotency-Key` that was itself cancelled used to carry on as if it were the first request; the cancellation now propagates. New shared helpers: `values.number`/`ip_address` and `common.load_json_file`/`save_json_file`/`basic_credentials`/`tls_fingerprint`. Agent unchanged. | | [v0.15.3](https://github.com/psewar/skirnir/releases/tag/v0.15.3) | 2026-10-21 | 0.8.10 | 0.19.0 | **`recall_with` can actually be configured.** 0.8.9 accepted `recall_with` in the memory project parser, but the configuration schema check, which runs first, did not know the key and refused every configuration that used it. The schema's client memory section is now derived from the fields the parser accepts, so the two cannot differ again; a selftest loads every accepted field through the schema check. **Reload no longer half-applies a configuration** (explicit control flow, part 2 of 4: configuration and settings): a reload (SIGHUP) that failed late in parsing left the running configuration half overwritten while the log said "keeping old"; it now validates a probe first and changes nothing on failure. Configuration errors are returned as values, and a failed write of `roles.yaml` (admin config change, client token rotation, catalog write after a measurement) is reported as an error instead of being treated as written. v0.15.2 also shipped part 1 of the explicit control flow conversion (proxy, request, admission, cloud; cloud errors are now recognised by the provider's error code instead of the message text). Agent unchanged. | | [v0.15.2](https://github.com/psewar/skirnir/releases/tag/v0.15.2) | 2026-10-21 | 0.8.9 | 0.19.0 | **Read a shared memory scope next to a private bank.** Hermes' Hindsight plugin serves a single bank, so a client granted a private bank and a shared scope could only ever read one of them. New memory project option `recall_with: [, ...]` (max 4, other projects of the same client with read access; loading refuses anything else): a recall of the project (gateway and `POST /v1/skirnir/memory/recall`) also recalls those projects at the same time and interleaves the facts rank by rank under one set of limits; a statement in both banks is passed on once. Facts from a further project carry `project` (REST) or `metadata.memory_project` plus a `[] ` text prefix (gateway). The answer reports each further project as `also` with its own `memory_state`; an unavailable, missing or withdrawn one adds nothing and never fails the private answer. Retains and automatic inference recall are unchanged. Agent unchanged. | | [v0.15.1](https://github.com/psewar/skirnir/releases/tag/v0.15.1) | 2026-10-21 | 0.8.8 | 0.19.0 | **Prewarm after a light game.** A node that came online (or became free) while a game was still being judged (`busy_enter.game_sustain_s`) skipped prewarm, and nothing planned it again: next to a light game the card stayed empty until the first request loaded a model cold. The router now remembers a prewarm skipped because of a game and runs it once the game counts as light or has ended and the node is still free (journal reasons `spiel-leicht`, `spiel-vorbei`); not while memory pressure is on, and not when the game turns out to need the GPU (the node goes busy, and `busy → free` prewarms anyway). Also: coming back online clears the busy reason from before the outage; `/admin/state` showed `free` with reason `game`. Agent unchanged. | | [v0.15.0](https://github.com/psewar/skirnir/releases/tag/v0.15.0) | 2026-10-21 | 0.8.7 | 0.19.0 | **Only a game that needs the GPU makes a node busy.** Since 0.13.0 every detected game made its node `busy`, whatever it used, so a small game (735 MiB of VRAM, 2 % load) blocked the whole card. Agent 0.19.0 measures the game's own GPU use over all its processes while it runs: dedicated and shared graphics memory and the highest engine load (Windows performance counters, 1.6 ms per sample). Router 0.8.7 counts the game once it needs `busy_enter.game_min_gpu_gib` (2 GiB, dedicated + shared) or `game_min_gpu_pct` (10 %), or during memory pressure, and then holds the node busy until the game ends. Shared memory counts because a game that gets no VRAM next to a large model moves there (the case that led to the rule). A light game leaves the node free and no longer blocks prewarm; an agent that does not report GPU use gets the old behaviour. `game_heavy` and its reason are in `/admin/state`, the change is in the journal as `game_heavy`. The agent now checks for games every second instead of every 5 s (about 17 ms per pass with 500 processes). | | [v0.14.7](https://github.com/psewar/skirnir/releases/tag/v0.14.7) | 2026-10-21 | 0.8.6 | 0.18.1 | **GOG Galaxy no longer counts as a game.** The agent keeps publishers' install folders as game folders but leaves out their launchers. The launcher list was matched case-sensitively, and GOG registers its launcher as `GOG GALAXY`, so its folder became a game folder: while `GalaxyClient.exe` ran, the node reported a game and refused requests with `gpu_busy`. Launcher names now match regardless of case (all launchers in the list). GOG installs games below the launcher folder; those games still count. Router unchanged. | | [v0.14.6](https://github.com/psewar/skirnir/releases/tag/v0.14.6) | 2026-10-20 | 0.8.6 | 0.18.0 | **Rerank-only clients.** A client without `roles` (or with an empty list) may use every role, so a client identified only by its source address for `POST /rerank` (a TEI client sends no header) could also run any inference. New optional client field `inference` (default `true`, unchanged behaviour): with `inference: false` the client may use only `POST /rerank` and `GET /info` (fixed allowlist); everything else on the API port - inference, memory gateway, session API - answers `403` with code `inference_disabled` in the caller's error format. Only real booleans are accepted (anything else aborts loading), and loading warns when a client has `rerank: true` but neither `inference: false` nor a `roles` limit. `roles` keeps its meaning. Agent unchanged. | | [v0.14.5](https://github.com/psewar/skirnir/releases/tag/v0.14.5) | 2026-10-20 | 0.8.5 | 0.18.0 | **Reranking on a node's integrated GPU.** A memory service such as Hindsight reranked every recall on the router host's CPU (2.3–3.7 s for 108 candidates on a 4-core server CPU, about 97 % of recall time, growing with the bank). New optional `rerank-server/`: a cross-encoder reranker with Hugging Face TEI's `/rerank` protocol on OpenVINO (Intel iGPU, CPU or NPU; default `mmarco-mMiniLMv2-L12`, multilingual), run as an agent child on loopback; it takes no VRAM of the large GPU and reports healthy only after a SHA-256 check of its model files and a fixed query pair. Router 0.8.5: `POST /rerank` and `GET /info` (TEI) for clients with the new right `rerank`; TEI clients send no header and may be identified by source address. No Wake-on-LAN, queue or retry: if no node can rerank, the router answers at once with `424 rerank_unavailable` and a reason (`disabled_node`, `node_offline`, `tunnel_down`, `reranker_down`, `gpu_busy`, `timeout`, `upstream_invalid`, `upstream_error`), never 503 and never `Retry-After`, because Hindsight's TEI client retries 5xx three times and has no circuit breaker. Disabled by default (`router.rerank`). Agent 0.18.0: optional `tunnel.routes` hand listed paths to further loopback services (exact path, listed methods, otherwise 405 without calling Ollama) and report their health every 5 s in the heartbeat field `routes`; without `routes` nothing changes. Measured end to end with Hindsight's own TEI client (hindsight-api-slim 1.0.0) on a Core Ultra iGPU: 437 ms per recall instead of 2.3–3.7 s, same top 10 as the router; with the reranker or the node gone, Hindsight falls back to its CPU reranker within 1–4 ms. Also: `Tunnel.open` now removes its stream and sends CANCEL when the caller is cancelled (the agent finished abandoned Ollama requests for nobody); agent tests also run on Windows in CI. | | [v0.14.4](https://github.com/psewar/skirnir/releases/tag/v0.14.4) | 2026-10-20 | 0.8.4 | 0.17.0 | **Refused queries, missing banks and private addresses; dates and context for gateway retains; ordered shutdown.** Explicit recall answers a query the sensitive-data filter refused with 403, `memory_state: query_filtered` and `error.reason` instead of an empty list (Hermes reported "No relevant memories found" for a question that never reached memory); inference proceeds without recall and carries the state header. A bank that does not exist yet reports `memory_state: bank_missing` instead of `available`. New memory project option `allow_private_addresses: true` lets only private IPv4 addresses pass the filter, for a memory about one's own network; it requires `allow_cloud: false`, also for a shared scope, and any other combination is refused at load time; secrets, e-mail addresses and `deny_terms` stay rejected. The Hindsight gateway's retain now passes the item's `timestamp` (ISO 8601 with offset, checked by pattern first; otherwise 400 with `error.reason`) and `context` (at most 256 characters, filtered like the content, appended after Skirnir's fixed notice; the job stays unconfirmed) on to Hindsight. Neither is part of the operation identity, so a replay stays one operation; older outbox rows keep their shape. Tags stay refused. On stop, the outbox worker and maintenance finish their current job before the database pool closes (a cancellation during `BEGIN` used to leave an open transaction, logged as "Resetting connection with an active transaction"); work that outlasts the timeout is counted as `shutdown_cancel`. The first maintenance cleanup also runs right after a host boot. | | [v0.14.3](https://github.com/psewar/skirnir/releases/tag/v0.14.3) | 2026-10-19 | 0.8.3 | 0.17.0 | **One recall setting.** How much memory reaches a context was spread over three settings that blocked each other: raising one alone silently changed nothing, and the Hermes provider's default `max_tokens: 4096` held every recall to 4 KiB whatever the server allowed. Now `recall_max_bytes` is the one setting. Unless set, `recall_max_tokens` (Hindsight's selection budget) is derived as the same number, `max_response_bytes` as max(64 KiB, 64 × that budget), and `recall_max_facts` applies no count cut; explicit values still win (remote or metered Hindsight, small models). A gateway client's `max_tokens` counts fact text only, as in Hindsight. Every recall reports which limit stopped it (`limited_by`: `bytes`, `facts`, `client` or `results`; `outcome="limited_"`). Defaults change: about 11 facts within 4 KB instead of at most 10, Hindsight budget 512 → 4096 tokens, response limit 64 KiB → 256 KiB. Replay of 27 test questions: same quality at the default; `recall_max_bytes: 8192` alone gives 19.7 facts and 86.2 % keyword coverage (from 82.2 %). | | [v0.14.2](https://github.com/psewar/skirnir/releases/tag/v0.14.2) | 2026-10-19 | 0.8.2 | 0.17.0 | **One statement, one recall place.** Hindsight returns an observation next to the single fact it was derived from (the fact with appended context, the observation with the bare statement); both took one of the 10 places, so a recall often carried only 6 to 8 different statements. Recall now asks for `include.source_facts` with a zero budget (each observation lists its `source_fact_ids` without their text, +2.6 % response size) and keeps only the higher-ranked of such a pair; an observation summarising several facts stays with its sources. Dropped repeats are counted as `outcome="duplicate"` and reported as `duplicates` on explicit recalls. The fixed limit of 10 facts, which had no recorded reason, is now `recall_max_facts` (default 10, 1-100); `recall_max_bytes` still applies. A replay of 27 test questions over a real bank's raw answers: probe hits 25 → 26 of 27, keyword coverage 79.7 % → 82.2 % at the same size. More than 10 facts helps only with a larger byte cap as well (table in design/hindsight-memory.md). | | [v0.14.1](https://github.com/psewar/skirnir/releases/tag/v0.14.1) | 2026-10-19 | 0.8.1 | 0.17.0 | **Memory failures are reported, not hidden as "no knowledge".** Recall carries its state as a fact: `available`, `query_filtered` or `unavailable[:]` (`timeout`, `transport`, `protocol`, `upstream_auth`, `upstream_rejected`). `fail_open` now applies to inference only: inference proceeds without facts and the response carries `X-Skirnir-Memory-State` (and `memory_state` in the `routing` block). Explicit recall (`/v1/skirnir/memory/recall` and the Hindsight gateway) answers 503 with `memory_state` instead of an empty list, which the official SDK raises as an error; before, an answer from a large bank above `max_response_bytes` arrived as an empty recall and the Hermes provider reported "No relevant memories found". Successful explicit recalls carry `memory_state` and `trimmed`. Recall no longer requests entity state from Hindsight (`include.entities: null`), which Skirnir never passed on. 400/413 answers add `error.reason`; a gateway retain names the refused option (e.g. `tags`). New setting `recall_max_bytes` (default 4096) caps the rendered recall separately from Hindsight's token budget `recall_max_tokens`: one shared limit of 512 dropped even a single short fact with its provenance; every fact dropped by the cap is counted as `outcome="trimmed"`. Explicit recall clients that relied on `200 []` during an outage now get 503. | | [v0.14.0](https://github.com/psewar/skirnir/releases/tag/v0.14.0) | 2026-10-19 | 0.8.0 | 0.17.0 | **Optional memory and session layer.** All parts are disabled by default and change nothing until enabled. A PostgreSQL session store with an authenticated session API (`/v1/skirnir/sessions`: append-only events, idempotent event ids, versioned task checkpoints). Scoped long-term memory through a self-hosted Hindsight service (`routing.memory`, recall as untrusted context, explicit retention). Managed session context with deterministic compaction (`skirnir-managed`, local only). Shared scopes, signed agent handoffs and a Hindsight-compatible gateway for the Hermes provider (`/v1/skirnir/hindsight`). A durable outbox with verified delivery: an extraction Hindsight reports as failed (e.g. while the local model is busy) is resubmitted later under a new upstream operation id; synchronous gateway retains wait for verified completion. Expected outcomes in this layer are returned as values, enforced in CI (`test/check_control_flow.py`, AGENTS.md). Deployment: the router now runs from a venv with system packages (`/opt/skirnir-router/venv`), which `deploy.py` creates and checks before replacing any file, so the session store gets a current asyncpg (Debian 12 ships 0.27). `render-env.sh` renders the optional memory secrets when they exist. PostgreSQL schema migrations 1-5 run on first start of an enabled session store. | | [v0.13.0](https://github.com/psewar/skirnir/releases/tag/v0.13.0) | 2026-10-05 | 0.7.0 | 0.17.0 | **Game and memory pressure as facts.** The agent reports a running game and the free main memory in every heartbeat (Windows: process path under a Steam library or a known publisher's install folder, by name for large programs when the path is not readable; `GlobalMemoryStatusEx`). A game running with little VRAM and little GPU load left the node free, and a model loaded into a nearly full RAM pushed the machine into heavy paging (94 % RAM, 1.8 GB free commit). Router 0.7.0: a game makes the node `busy` with reason `game` after `busy_enter.game_sustain_s` (10 s) and keeps it busy for `busy_exit.game_quiet_s` (60 s) after it ends; `gpu_busy` names the game. Memory pressure (available RAM or free commit below `memory_pressure.min_free_gib`, 8 GiB, for `memory_pressure.polls` heartbeats, 2) blocks cold loads and prewarm, unloads nothing, and answers with the new reason code `memory_pressure` (`Retry-After` 60 s). HA sensors per node: game, free RAM, free commit. New agent verb `spiel` prints the detection once. Agents before 0.17.0 keep the old behaviour. | | [v0.12.9](https://github.com/psewar/skirnir/releases/tag/v0.12.9) | 2026-10-04 | 0.6.9 | 0.16.0 | Model catalog from `config.yaml` and `roles.yaml` is merged field by field. Until now an entry in `roles.yaml` (a measurement from the UI) replaced the `config.yaml` entry entirely, so fields that only `config.yaml` set did not apply (`max_parallel` for one model, a capability override for another). Size fields (`weights_gib`, `kv_gib_per_1k`, `real`, measured values) still replace each other as a unit, so new weights are never mixed with an old measurement. New selftest `selftest_katalog_merge.py` in CI. UI: the full-width chart "Models actually used" collapses to its legend line while there were no requests in the last 10 minutes. | | [v0.12.8](https://github.com/psewar/skirnir/releases/tag/v0.12.8) | 2026-10-04 | 0.6.8 | 0.16.0 | Hysteresis for busy detection. A busy node had to keep meeting the entry condition (load above 40 % without a break for `sustain_s`); a light game at 31–44 % load flipped the node between busy and free every 20–60 s. Now it stays busy while the load is above `busy_exit.gpu_util_pct` (new, default 20 %, at most the entry threshold) and foreign VRAM is in use; it becomes free only after `below_for_s` of quiet. | | [v0.12.7](https://github.com/psewar/skirnir/releases/tag/v0.12.7) | 2026-10-02 | 0.6.7 | 0.16.0 | Observation mode (`decision_engine.observe_blocked`): when a request for a role has to wait, the decision engine classifies it in the background with its CPU chain only (never the LLM stage, which would queue on the busy GPU) and the decision log records `decision_observe` with class, confidence, the first free lower tier of the same role and `would_fallback`. Routing does not change; this measures how often a free smaller model would have been enough. `observe_all` classifies every request in the background and appends one line per request to `anfragen.jsonl` (class, duration, waiting time, tokens, thinking, tools, client, role, outcome; no contents), to check whether the class predicts run time. | | [v0.12.6](https://github.com/psewar/skirnir/releases/tag/v0.12.6) | 2026-10-02 | 0.6.6 | 0.16.0 | Interactive requests no longer queue behind a busy warm model if another local tier can serve right away (first a free warm tier, then a free cold one, never cloud on this path; decision log `reason: ausweichen`). Warm first held on to the busy warm node: with `max_parallel: 1` a voice command could wait up to 60 s behind an agent request of several minutes while a second node had a free tier. Normal and batch requests keep waiting for the warm model. | | [v0.12.5](https://github.com/psewar/skirnir/releases/tag/v0.12.5) | 2026-10-02 | 0.6.5 | 0.16.0 | **Models without parallel requests wait in the router.** New catalog field `models..max_parallel`. qwen3.8 (architecture qwen35) cannot run two requests at once in Ollama; a second request sent anyway waited in Ollama's queue, and Ollama reloaded the runner as soon as the first one finished (twice in one night, 5–7 s each, context checkpoints and prompt cache lost). With `max_parallel: 1` the router holds the second request in its own queue, with priority, and wakes the waiter that can actually start. Waiting budget per priority class: `max_wait_interactive_s` 60 s, `max_wait_s` (normal) now 600 s instead of 120 s, `max_wait_batch_s` 1800 s. New selftest `selftest_max_parallel.py` in CI. | | [v0.12.4](https://github.com/psewar/skirnir/releases/tag/v0.12.4) | 2026-10-02 | 0.6.4 | 0.16.0 | Errors in the middle of a stream now count as errors. Ollama ends a streamed answer with `{"error": ...}` when the model writes a tool call with broken XML (seen: `XML syntax error on line 3: unexpected EOF`); the router passed the line on and counted the request as ok. It now records outcome `error`, logs the message, adds a `stream_error` entry to the decision log, and Anthropic and OpenAI clients get an error event in their own form (Ollama clients still get Ollama's line verbatim). The node's circuit breaker does not count it: the node works, the model's output was broken. | | [v0.12.3](https://github.com/psewar/skirnir/releases/tag/v0.12.3) | 2026-10-01 | 0.6.3 | 0.16.0 | TCP keepalive on the inference port (first probe after 60 s, then every 15 s, dead after 4). A non-streaming request ran 6.6 minutes without a single byte; connection state on the way to the client expired silently, the finished answer never arrived and the router counted the request as ok. The probes keep the state fresh and make a dead connection show up as an error. New selftest `selftest_keepalive.py` checks that accepted connections inherit the options. | | [v0.12.2](https://github.com/psewar/skirnir/releases/tag/v0.12.2) | 2026-10-01 | 0.6.2 | 0.16.0 | New setting `router.anthropic.think` (`auto`, `on`, `off`; in the UI, applies without restart): `auto` follows the client's `thinking` block as before, `on` and `off` override it. Claude Code asks for adaptive thinking on every turn, and a model with only on or off then thinks on every turn. The decision log now records `think` for every request. | | [v0.12.1](https://github.com/psewar/skirnir/releases/tag/v0.12.1) | 2026-10-01 | 0.6.1 | 0.16.0 | Fix for `/v1/messages` with Claude Code 2.x, found in the first acceptance run: the CLI sends `role: system` inside `messages` (not part of the public API) and the router rejected the request with 400; such messages now go on as a `system` message at the same position. `thinking: {type: adaptive, display: omitted}` gives `think: true` and thinking blocks without text, with `ping` events while the model thinks so the stream does not go silent. | | [v0.12.0](https://github.com/psewar/skirnir/releases/tag/v0.12.0) | 2026-10-01 | 0.6.0 | 0.16.0 | **Anthropic Messages API.** `POST /v1/messages` (streaming and not) and `/v1/messages/count_tokens`, translated to `/api/chat` through the scheduler, so Claude Code and other Anthropic clients get roles, the tier's context (or the loaded one), warm first, `gpu_busy` waiting and tool-call rescue instead of Ollama's own `/v1/messages`, which knows no `num_ctx` and reloads the model. Streaming follows Anthropic's event order, with `ping` events while a long prompt is being processed (`router.anthropic.keepalive_s`, 15 s). No capacity is HTTP 529 `overloaded_error` with `Retry-After`, which Claude Code retries by itself. Client identity also via `x-api-key`; `GET /v1/models` answers in Anthropic form when the request carries `anthropic-version`. New selftest `selftest_anthropic_messages.py` in CI. | | [v0.11.1](https://github.com/psewar/skirnir/releases/tag/v0.11.1) | 2026-10-01 | 0.5.0 | 0.16.0 | The agent keeps the Windows uninstall entry of Ollama current. winget and UniGetUI read the installed version only from that entry; since the agent swaps Ollama's files itself, it kept showing the old version, so winget offered every release and the installer then failed against the Ollama running as a child of the service. The agent now sets `DisplayVersion` and `DisplayName` after every swap and reconciles them 30 s after start and hourly (a user's registry hive is only loaded while that user is signed in). Tray installations are left alone. Recommended on such nodes: `winget pin add --id Ollama.Ollama --blocking`. | | [v0.11.0](https://github.com/psewar/skirnir/releases/tag/v0.11.0) | 2026-10-01 | 0.5.0 | 0.15.0 | **Ollama swap only after the router's release.** The node keeps serving requests while the agent downloads and verifies the new Ollama; then the agent reports `ready`, the router takes the node out of routing, waits until no request is running and sends `ollama-swap`. Without the release nothing is swapped (agent waits 30 min, router gives up after 25 min if the node does not drain). No prewarm while an Ollama update runs (before, a prewarm loaded a large model that the swap stopped 30 s later), one prewarm when it is done. The *Ollama auto-update* option is greyed out for nodes whose Ollama runs as a tray app. Agents before 0.15.0 keep the old behaviour (node drained from the order on). | | [v0.10.1](https://github.com/psewar/skirnir/releases/tag/v0.10.1) | 2026-09-30 | 0.4.1 | 0.14.1 | Fix for 0.14.0: with nothing loaded the agent sent `ps: null` (a Go nil slice) because it only took over a list when it changed, and the router discarded the report and kept the node offline until the last valid report expired. The agent now always sends lists and counts its first answer as a change; the router treats `null` as an empty list and falls back to polling at once when a report is unusable. New selftest `selftest_ollama_push.py`. | | [v0.10.0](https://github.com/psewar/skirnir/releases/tag/v0.10.0) | 2026-09-30 | 0.4.0 | 0.14.0 | **The agent reports Ollama's state instead of the router polling it.** The agent watches its local Ollama (loaded models every second, installed models every 5 s) and sends installed models, loaded models and whether Ollama answers as a new tunnel frame `OLLAMA`, immediately on every change and in full at least every 30 s. After three failures in a row it reports `up: false` with the reason, and the router takes the node out of routing at once. While these reports are fresh the router no longer polls the node; older agents and static nodes are still polled every 5 s. Node state shows `ollama_source: push|poll`. Poll and push share one evaluation path in the router. Two router fixes the faster state exposed: a delayed prewarm now only runs for the free transition it was planned for (before, a prewarm planned at one transition fired right after the next one), and the busy unload safety net runs from the one-second tick instead of after each poll. Two end-to-end checks now expect the 0.3.7 behaviour (a role request with a smaller context reuses the loaded one). | | [v0.9.6](https://github.com/psewar/skirnir/releases/tag/v0.9.6) | 2026-09-30 | 0.3.8 | 0.13.0 | **The agent reports its children's GPU memory; loaded context is reused.** The heartbeat carries `children_vram_mib`: dedicated GPU memory of every running child except Ollama, measured per process with the Windows counter `\GPU Process Memory(*)\Dedicated Usage` over all processes in the child's job object (`skirnir-agent gpu` lists them; Windows only). The router no longer counts it as foreign VRAM and leaves it out of the learned baseline; a speech-to-text child on CUDA (2.5 GiB) otherwise pushed the node over the busy threshold (desktop plus a light game 3.6 GiB, with the child 6.2 GiB). Router 0.3.7: a role request with a smaller `num_ctx` than the loaded context (up to the tier's limit) gets the loaded context, because some models do not share a runner across `num_ctx` values and reloaded on every switch between a 64k and a 128k client (7 to 10 s). New self-tests `selftest_kontext_teilen.py` and `selftest_kinder_vram.py` in CI. | | [v0.9.5](https://github.com/psewar/skirnir/releases/tag/v0.9.5) | 2026-09-30 | 0.3.6 | 0.12.1 | **Real VRAM measurement, fixed.** The 0.3.5 measurement read the empty baseline too early: after unloading a big model `/api/ps` is empty at once, but nvidia-smi frees the memory seconds later, so two equal heartbeats still showed the full card and the first point came out negative (weights 0.1 GiB in the catalog). It now waits until at least 90 % of what `/api/ps` reported is freed, waits for loads started by others (the prewarm after the previous measurement), checks that only the measured model is loaded, and discards a point that is smaller than Ollama's own figure (up to three tries per point; a measurement that stays implausible writes nothing). Points that spill into system RAM count with the spilled part instead of being dropped, so the catalog knows the model does not fit at that context. Measured entries no longer get the 0.8 GiB fit overhead (it compensated for `/api/ps`); the budget reserve stays. Re-measure models measured with 0.3.5. | | [v0.9.4](https://github.com/psewar/skirnir/releases/tag/v0.9.4) | 2026-09-30 | 0.3.5 | 0.12.1 | **Why no node, and real VRAM numbers.** A request that gets no node now carries a reason code instead of a bare *no node available*: `gpu_busy`, `node_offline`, `node_draining`, `node_failing`, `vram_full`, `model_too_large`, `model_missing` or `attempt_failed`, with `Retry-After` where waiting helps and the obstacle per node (need, budget, foreign VRAM). OpenAI clients find it in `error.code` / `error.retry_after` / `error.skirnir.blockers`. `gpu_busy` also covers a node that is *free* while foreign VRAM (a game) takes exactly the missing room. New `GET /v1/skirnir/availability/` (client auth, read-only) tells a waiting client when a request would get a node. The decision log shows the reason (yellow for `gpu_busy`). Router 0.3.5: `/admin/measure` measures a model's real footprint from the agent's nvidia-smi values (everything unloaded, baseline read, model loaded, at 8k, 32k and every context a role uses) instead of `/api/ps`, which reports only part of it (21.0 instead of 26.4 GiB for a 35B MoE at 196k). Loaded models count with that real footprint in the budget, in foreign VRAM and in the unload hold; without a real measurement nothing changes. The state machine pauses during a measurement; saving the catalog in the UI keeps the measurement. After upgrading, measure the models you route to and lower a node's `foreign_vram_baseline_gib` if it was raised to hide the unreported part. | | [v0.9.3](https://github.com/psewar/skirnir/releases/tag/v0.9.3) | 2026-09-27 | 0.3.3 | 0.12.1 | The agent reports on every connect whether Ollama is one of its children (`ollama_managed` in the agent facts). The router offers and orders Ollama updates only for nodes that report `true`; a node reporting `false` shows *tray app* and cannot be ordered even with `force`. Replaces the 0.3.2 workaround that learned it from the agent's refusal. Agents older than 0.12.1 get no Ollama orders unless forced. | | [v0.9.2](https://github.com/psewar/skirnir/releases/tag/v0.9.2) | 2026-09-27 | 0.3.2 | 0.12.0 | Nodes whose Ollama is not a child of the agent (tray app under the desktop user, which updates itself) are no longer offered router-driven Ollama updates: the router learns it from the agent's refusal, shows *tray app* in the Agents tab, skips the node in the rollout, raises no Home Assistant problem and does not count it as pending. A forced order from the API still goes out. The next agent build reports it as a fact (`ollama_managed`). | | [v0.9.1](https://github.com/psewar/skirnir/releases/tag/v0.9.1) | 2026-09-27 | 0.3.1 | 0.12.0 | Ollama update window in a configurable time zone (`modes.ollama_update.timezone`); the router container runs on UTC, so the first update ran three hours late. Agent update retried after 30 minutes instead of six hours when Smart App Control or WDAC blocked the new binary, because the cloud verdict on an unsigned build often changes within minutes to hours. | | [v0.9.0](https://github.com/psewar/skirnir/releases/tag/v0.9.0) | 2026-09-26 | 0.3.0 | 0.12.0 | **Ollama updates through the router.** With Ollama running as a child of the agent service, the tray app's updater no longer applies; the router now checks the latest Ollama release (GitHub API + `sha256sum.txt`), shows it per node in the *Agents* tab and, with the per-node policy *Ollama auto-update*, updates nodes inside a night window (`modes.ollama_update`, default 02:00 to 05:00), canary first, only when free. The agent downloads the archive from its own fixed source, verifies it against the release checksums, holds the Ollama child, swaps `ollama.exe` and `lib`, restarts and rolls back if the new version does not answer. Heartbeat carries the Ollama version every 60 s. HA sensor *Ollama-Updates ausstehend*, metric `skirnir_node_ollama_update_pending`, failures as HA problems. Windows nodes only. | | [v0.8.1](https://github.com/psewar/skirnir/releases/tag/v0.8.1) | 2026-09-26 | 0.2.0 | 0.11.1 | Agent `setup` validates the migrated configuration **before** it stops the old service or moves the directory; if the rewritten file would not load, nothing is changed and the old service keeps running. A secret-store block under the old block name (before 0.9.1) is renamed to `secret_store:` automatically. Errors are printed before the Enter pause so they stay visible in the UAC window. Background: on one node the 0.11.0 setup removed the old service and then failed on the old configuration, leaving the node without an agent. | | [v0.8.0](https://github.com/psewar/skirnir/releases/tag/v0.8.0) | 2026-09-25 | 0.2.0 | 0.11.0 | **Renamed** to match the product: Python package `skirnir_router`, service and paths `skirnir-router` (`/opt`, `/etc`, `/var/log`, `/var/lib`), agent binary `skirnir-agent`, Windows service `SkirnirAgent` ("Skirnir Agent"), task `SkirnirAgent-GpuzRelay`, Linux `/etc/skirnir-agent`; repo directory `agent-go` is now `agent`. `deploy.py` moves an existing router installation once; the agent keeps finding the old configuration path, service and task names until `setup` has migrated the node. MQTT side renamed as well (base topic `skirnir-router`, discovery node id `skirnir_router`, unique ids `skirnir_router_*`, entity ids `sensor.skirnir_*`); the router clears the old retained topics once, custom entity ids are carried over by an operator script. Agent setup by double-click: the binary elevates itself (UAC), asks for router address, update key and control user, installs or updates the service *Skirnir Agent* (LocalSystem, technical name unchanged) including the GPU-Z relay task, and shows health and fingerprint. `Install-Service.ps1` checked the admin role by the English group name and refused on a German Windows; fixed, script kept for special cases. | | [v0.7.0](https://github.com/psewar/skirnir/releases/tag/v0.7.0) | 2026-09-25 | 0.1.9 | 0.9.2 | Counters and histograms behind `/metrics` persist in `metrics.json` next to the configuration (a minute after a change and at shutdown), so a deploy no longer resets them; the UI labels them "total" since the recording began and `/admin/state` carries `metrics_since`. Registered agents moved from the overview to their own *Agents* tab (dot while an agent waits for approval). New `monitoring/`: Prometheus (90 d retention) and Grafana with a provisioned Skirnir dashboard as two containers next to the router, secrets from the secret store, certificate shared with the router, `deploy_ct.py`. | | [v0.6.0](https://github.com/psewar/skirnir/releases/tag/v0.6.0) | 2026-09-25 | 0.1.8 | 0.9.2 | Web UI in English or German (toggle in the header, browser language by default; settings labels come translated from `/admin/config?lang=`); router API messages, cloud skip reasons and Prometheus HELP texts in English (the operator log and Home Assistant texts stay German). Internals: shared circuit breaker for nodes and cloud targets with a pure read-path variant (the HA snapshot and `/metrics` no longer flip breakers), `Node.go_offline`, one env-file parser, one tool-argument parser, constants in one place, `Config.parse` split into sections with runtime defaults from the settings table, background tasks held in `state.TASKS`; ruff runs Bugbear, BLE and RUF006. Agent 0.9.2: `status`/`start`/`stop`/`restart` work without read access to the configuration (control users lost it in 0.9.0). README: the two names, Skirnir and ollama-router. | | [v0.5.1](https://github.com/psewar/skirnir/releases/tag/v0.5.1) | 2026-09-25 | 0.1.7 | 0.9.1 | Legacy agent path removed: the token WebSocket tunnel (`/v1/tunnel/`) is gone from the router, the agent no longer has an HTTP heartbeat mode (`tunnel.enabled`, `router.interval_s/timeout_s`) and no longer reads the secret-store block under the old block name (use `secret_store:`); `router.token` remains only for the TLS proxy. `POST /v1/heartbeat/` stays as a token-protected lever for tests and diagnostics. The updater now runs `check-config` with the new binary before swapping, so a rejected configuration ends as a failed order instead of a restart loop. Plus the deploy and repo hygiene below. | | (same tag) | | | | Deploy and repo hygiene after the code review: `deploy.py` restarts only after the on-host check passes (was chained behind `\|\|`), pins the SSH host key (`/known_hosts`), keeps the UI password and the secret-store client secret off command lines, and the deploy manifest no longer records a workstation path or user name. Router unit gets `StateDirectory=` so decision capture works under `ProtectSystem=strict`. Legacy PowerShell agent (`agent/`) and the one-off `tools/split_router.py` removed; self-tests renamed to `test/selftest_*.py`; `requirements.txt`, `requirements-dev.txt`, `SECURITY.md`, this changelog and a GitHub Actions workflow added; design notes carry status banners. | | [v0.5.0](https://github.com/psewar/skirnir/releases/tag/v0.5.0) | 2026-09-25 | 0.1.6 | 0.9.0 | Agent review fixes: control users lose Modify on the config directory; `/restart-child` needs `X-Agent-Token`; the guard judges load on NVML board power and recognises its own targets after a restart; NVML fallback under the mutex; a module panic stops the service; parked `.old-*` cleaned; config defaults next to `--config`; `secret_store.domain` required; proxy refuses to run without a token; systemd unit allows self-update. | | [v0.4.1](https://github.com/psewar/skirnir/releases/tag/v0.4.1) | 2026-09-25 | 0.1.6 | 0.8.4 | Router review fixes: model catalog renders again (duplicate `renderModels`), Basic Auth hashing off the event loop with a failure cache and per-address throttle, MQTT connect callback no longer publishes from paho's thread, `tls_set` independent of the username, "Try it" finds its decision by request id, auto role can be enabled at runtime, MAC validation, registry inputs not overwritten while typing. | | [v0.4.0](https://github.com/psewar/skirnir/releases/tag/v0.4.0) | 2026-09-25 | 0.1.5 | 0.8.4 | Agent updates through the router: `deploy.py --agent` signs a manifest with an operator Ed25519 key; agents verify, download with a one-time token, swap themselves and restart; per-node policy, canary node, rollout loop, HA problems for failures and stalls. | | [v0.3.3](https://github.com/psewar/skirnir/releases/tag/v0.3.3) | 2026-09-25 | 0.1.4 | 0.7.1 | Decision log persists in `events.jsonl` next to the configuration (2000 entries, compaction); log size selector, filter and scrolling in the UI; quiet heartbeat ack on a closing tunnel. | | [v0.3.2](https://github.com/psewar/skirnir/releases/tag/v0.3.2) | 2026-09-25 | 0.1.3 | 0.7.1 | Requests wait for a model that is being loaded instead of getting 503 during a model switch (`wait_load`). | | [v0.3.1](https://github.com/psewar/skirnir/releases/tag/v0.3.1) | 2026-09-25 | 0.1.2 | 0.7.1 | GPU guard: short load gaps no longer reset the high-load counter (`high_load_gap_s`), measured in the stage-2 night run. | | [v0.3.0](https://github.com/psewar/skirnir/releases/tag/v0.3.0) | 2026-09-24 | 0.1.2 | 0.7.0 | GPU guard for the 12V-2x6 connector: power limit 80 % of the card default, stage 2 after sustained full load, voltage and temperature warnings, opt-out counts as a Home Assistant problem; router side with per-node policy, score penalty and inflight cap. | | [v0.2.0](https://github.com/psewar/skirnir/releases/tag/v0.2.0) | 2026-09-24 | 0.1.1 | 0.6.0 | GPU sensors in the heartbeat (NVML temperature, power, limits, fan, throttle reasons) plus GPU-Z shared memory (memory and hot-spot temperature, 16-pin power and voltage, PerfCap) via a relay task; Home Assistant entities. | | [v0.1.0](https://github.com/psewar/skirnir/releases/tag/v0.1.0) | 2026-09-18 | 0.1.0 | 0.5.1 | First public release: roles and tiers, scheduler with busy detection, admission, decision engine, cloud tiers, client identities, web UI, agent with outbound tunnel. |