nixos/hosts/FredOS-Mediaserver.nix
rope ba9558ba30 qs-ask: answer bare keyword queries instead of replying UNKNOWN
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 10:46:55 +01:00

182 lines
9.1 KiB
Nix

{ config, pkgs, lib, ... }:
{
config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") {
# Create symlink from home to storage
systemd.tmpfiles.rules = [
"L+ /home/fred/storage - - - - /mnt/storage"
];
# Basic system packages
environment.systemPackages = with pkgs; [
mergerfs
wget
util-linux
javaPackages.compiler.temurin-bin.jre-25
unzip
screen
yt-dlp
ghostty.terminfo
usbutils
lm_sensors
(pkgs.writeShellScriptBin "transcode-hevc" ''
export PATH="${pkgs.jellyfin-ffmpeg}/bin:${pkgs.coreutils}/bin:${pkgs.findutils}/bin:${pkgs.gnugrep}/bin:${pkgs.gawk}/bin:${pkgs.bc}/bin:${pkgs.curl}/bin:$PATH"
exec ${pkgs.bash}/bin/bash ${../scripts/transcode-hevc.sh} "$@"
'')
(pkgs.writeShellScriptBin "record-update" ''
export PATH="${pkgs.nvd}/bin:${pkgs.coreutils}/bin:${pkgs.gnugrep}/bin:${pkgs.gnused}/bin:$PATH"
exec ${pkgs.bash}/bin/bash ${../scripts/record-update.sh} "$@"
'')
# Stats stream for the quickshell server monitor on the desktops:
# `ssh mediaserver qs-stats` = top's 2s batch stream, interleaved with
# one @STAT line per tick (hottest coretemp across both sockets, WAN
# byte rates from eno1). Everything rides one SSH connection.
(pkgs.writeShellScriptBin "qs-stats" ''
# Single writer: the loop re-emits top's lines itself and injects a
# @STAT line at each frame header. top writing the pipe directly in
# parallel raced the injected lines (pipe writes aren't line-atomic)
# and spliced @STAT mid-frame, where the client parser never saw it.
C=${pkgs.coreutils}/bin
export LC_ALL=C
prev_rx=""
${pkgs.procps}/bin/top -b -d 2 -w 512 | while IFS= read -r line; do
printf '%s\n' "$line"
case $line in
"top - "*)
t=0
for h in /sys/class/hwmon/*; do
[ "$($C/cat "$h/name" 2>/dev/null)" = coretemp ] || continue
for f in "$h"/temp*_input; do
v=$($C/cat "$f" 2>/dev/null || echo 0)
[ "$v" -gt "$t" ] && t=$v
done
done
read -r rx tx < <(${pkgs.gawk}/bin/awk '$1 == "eno1:" {print $2, $10}' /proc/net/dev)
if [ -n "$prev_rx" ]; then
printf '@STAT temp=%s rxbps=%s txbps=%s\n' "$((t / 1000))" "$(( (rx - prev_rx) / 2 ))" "$(( (tx - prev_tx) / 2 ))"
fi
prev_rx=$rx
prev_tx=$tx
;;
esac
done
'')
# Instant-answer backend for the quickshell launcher on the desktops:
# `printf '%s' "question" | ssh mediaserver qs-ask`.
#
# Runs through Claude Code so it draws on fred's existing subscription —
# no API key, no separate billing. The trade is latency: Claude Code boots
# a Node process per question, so this takes ~6s against the raw API's
# ~1s. The launcher therefore shows its Wikipedia answer first and lets
# this one supersede it when it lands.
#
# Reads the question from stdin — passing it as an ssh argv element would
# send it through the remote shell for a second round of word splitting.
#
# Every flag here is either isolation or trimming the invocation:
# --safe-mode no CLAUDE.md, skills, hooks, plugins, MCP or
# custom agents. Critical for correctness, not
# just speed: fred's ~/.claude/CLAUDE.md tells
# Claude to answer in caveman mode, which would
# otherwise leak into launcher answers. Auth
# keeps working normally, which is what makes
# the subscription usable here.
# --no-session-persistence writes no transcript, so launcher questions
# never appear in /resume or any project history
# --tools "" no tools at all: nothing to load, and it
# cannot touch the filesystem or network
# --disable-slash-commands no skill resolution
# --strict-mcp-config ignore every MCP config (none supplied)
# --system-prompt replaces the full Claude Code system prompt
# with one line, rather than appending to it
# --permission-mode dontAsk never block waiting on a prompt nobody sees
# --model claude-haiku-4-5 smallest model — a one-line fact needs no more
#
# NOT --bare, which looks like the right flag and is a trap: it skips even
# more, but its auth is "strictly ANTHROPIC_API_KEY or apiKeyHelper (OAuth
# and keychain are never read)" — i.e. it cannot use the subscription.
#
# STDOUT is the launcher's channel: the answer, or nothing at all if the
# call fails or the model doesn't know — silence is what the launcher
# reads as "keep the Wikipedia answer". Every reason for that silence goes
# to STDERR instead, which the launcher discards and a human running
# `qs-ask` by hand can read.
(pkgs.writeShellScriptBin "qs-ask" ''
q=$(${pkgs.coreutils}/bin/cat)
[ -z "$q" ] && { echo "qs-ask: no question on stdin" >&2; exit 0; }
# Claude Code derives its project state (transcripts, auto-memory) from
# the working directory, so run from a dedicated empty one. Combined with
# --no-session-persistence and --safe-mode, a launcher question leaves no
# trace in the real projects' history or memory.
work="$HOME/.cache/qs-ask"
${pkgs.coreutils}/bin/mkdir -p "$work" || { echo "qs-ask: cannot create $work" >&2; exit 0; }
cd "$work" || { echo "qs-ask: cannot enter $work" >&2; exit 0; }
# MAX_THINKING_TOKENS=0 is the single biggest win here, worth ~2.4x.
# Measured: Haiku defaults to extended thinking, and it spent 225 output
# tokens and 1.9s deliberating before emitting an 80-character answer
# about the height of a tower. With thinking off: 43 output tokens,
# time-to-first-text 2657ms -> 1122ms, wall clock 5.7s -> 2.4s, same
# answer. A one-line lookup has nothing to reason about.
#
# --name is not cosmetic: without it Claude Code fires a second,
# concurrent `source=generate_session_title` request to invent a title
# for a session --no-session-persistence then throws away. It costs no
# wall time (both requests dispatch ~1ms apart), but it spent 507 input
# tokens against 153 for the real question. Naming the session up front
# removes it: one request instead of two, 660 input tokens -> 153.
#
# For the record, two things that are NOT the bottleneck, both measured:
# process startup (0.12s boot + 28ms to fire the request, so keeping a
# warm process saves nothing) and prompt size (184 input tokens).
ans=$(printf '%s' "$q" | ${pkgs.coreutils}/bin/env MAX_THINKING_TOKENS=0 \
${pkgs.claude-code}/bin/claude -p \
--safe-mode \
--no-session-persistence \
--tools "" \
--disable-slash-commands \
--strict-mcp-config \
--permission-mode dontAsk \
--output-format text \
--model claude-haiku-4-5 \
--name qs-ask \
--system-prompt 'Answer the question in one or two short sentences, under 240 characters. Lead with the specific fact asked for, including units. The query is often bare keywords rather than a question ("shadow hunter knife classic wow"); treat that as a request for the key facts about that thing, not as unanswerable. No preamble, no caveats, no markdown, no follow-up offers. Reply with exactly UNKNOWN only when you genuinely do not know, or the answer depends on live data you do not have.' \
2>/dev/null) \
|| { echo "qs-ask: claude exited non-zero (auth expired? run 'claude' once to log in)" >&2; exit 0; }
ans=$(printf '%s' "$ans" | ${pkgs.coreutils}/bin/tr -d '\r' | ${pkgs.gnused}/bin/sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
case "$ans" in
"") echo "qs-ask: empty answer" >&2; exit 0 ;;
UNKNOWN*) echo "qs-ask: model replied UNKNOWN" >&2; exit 0 ;;
esac
printf '%s' "$ans"
'')
];
# Basic networking
networking.useDHCP = lib.mkForce false;
# Allow fred to act as a remote Nix builder (trusted users can import
# unsigned store paths sent by the build client).
nix.settings.trusted-users = [ "root" "fred" ];
# Automatic daily system updates
system.autoUpgrade = {
enable = true;
flake = "git+https://forg.gregersen.it/rope/nixos";
dates = "05:15";
allowReboot = true;
};
# WAN exposure is controlled by nftables in services/router.nix +
# ports.toml (networking.firewall is disabled on this host).
services.openssh = {
enable = true;
settings = {
PermitRootLogin = "no";
PasswordAuthentication = false;
};
};
};
}