1{ 2 description = "jevstrudel: Strudel with an MCP bridge and a song browser, plus the songs"; 3 4 inputs = { 5 nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 6 # wrangler alone, from a newer nixpkgs than the pin: 4.129.0's `wrangler 7 # dev` dies on a broken pipe when a client disconnects (worker/CLAUDE.md). 8 # The overlay below throws once `nixpkgs` catches up, to be dropped. 9 nixpkgs-wrangler.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 10 # The estate's deploy machinery (1Password-fed wrangler) and identity 11 # (account id, credential refs), as navi consumes them. 12 nix-pkgs.url = "git+ssh://git@github.com/deizel/nix-pkgs"; 13 nix-pkgs.inputs.nixpkgs.follows = "nixpkgs"; 14 nix-facts.url = "git+ssh://git@github.com/deizel/nix-facts"; 15 }; 16 17 outputs = 18 { 19 self, 20 nixpkgs, 21 nixpkgs-wrangler, 22 nix-pkgs, 23 nix-facts, 24 }: 25 let 26 system = "x86_64-linux"; 27 # The pinned nixpkgs with only wrangler taken from nixpkgs-wrangler, so 28 # the devshell, the worker-types check and the deploy 29 # (mkWranglerDeploy reads pkgs.wrangler) all run the same one. Once the 30 # pin's own wrangler is as new, this throws: drop the input and the 31 # overlay rather than carry a downgrade. 32 pinned = nixpkgs.legacyPackages.${system}; 33 newer = nixpkgs-wrangler.legacyPackages.${system}.wrangler; 34 pkgs = 35 if pinned.lib.versionOlder pinned.wrangler.version newer.version then 36 pinned.extend (_: _: { wrangler = newer; }) 37 else 38 throw "nixpkgs now has wrangler ${pinned.wrangler.version} (nixpkgs-wrangler: ${newer.version}); drop the nixpkgs-wrangler input and its overlay from flake.nix"; 39 # This repo is the Strudel fork: upstream Strudel, the strudel-llm MCP 40 # commits rebased on it, and the songs (see README). 41 strudel = pkgs.callPackage ./nix/strudel.nix { src = self; }; 42 43 # Vocal tooling: `speak` renders text with a Windows SAPI voice (so it 44 # needs the host's windows-exec, not packaged here), and `vocode` makes 45 # a chord sing it. 46 vocode = pkgs.writers.writePython3Bin "vocode" { 47 libraries = with pkgs.python3Packages; [ 48 numpy 49 scipy 50 ]; 51 flakeIgnore = [ "E501" ]; 52 } (builtins.readFile ./tools/vocals/vocode.py); 53 54 speak = pkgs.writeShellApplication { 55 name = "speak"; 56 runtimeInputs = [ 57 pkgs.coreutils 58 pkgs.python3 59 ]; 60 text = '' 61 if [ $# -lt 2 ]; then 62 echo "usage: speak TEXT OUT.wav [VOICE] [RATE -10..10]" >&2 63 exit 2 64 fi 65 text=$1 66 out=$(realpath -m "$2") 67 voice=''${3:-Microsoft David Desktop} 68 rate=''${4:-0} 69 70 # windows-exec's PowerShell cannot see \\wsl.localhost, so the script 71 # travels inline and the WAV comes back through Windows' temp dir. 72 # </dev/null everywhere: windows-exec would otherwise eat stdin. 73 tmp=$(windows-exec powershell -NoProfile -Command '[IO.Path]::GetTempPath()' </dev/null | tr -d '\r') 74 win_out="''${tmp}speak-$$-$RANDOM.wav" 75 q() { printf "'%s'" "''${1//\'/\'\'}"; } 76 cmd="& { $(cat ${./tools/vocals/speak.ps1}) } -Text $(q "$text") -Out $(q "$win_out") -Voice $(q "$voice") -Rate $rate" 77 enc=$(printf '%s' "$cmd" | python3 -c 'import base64,sys; print(base64.b64encode(sys.stdin.read().encode("utf-16-le")).decode())') 78 windows-exec powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand "$enc" </dev/null 79 mv "$(wslpath -u "$win_out")" "$out" 80 test -s "$out" 81 ''; 82 }; 83 84 # The static site on workers.dev, public, plus the Jev relay: the 85 # Worker in worker/, its production environment. 86 # Cloudflare credentials come from 1Password per run, never from disk. 87 cloudflare = { 88 itemRef = nix-facts.facts.onePassword.cloudflareMasterKey; 89 accountId = nix-facts.facts.cloudflare.accounts.deizel; 90 }; 91 wranglerDeploy = nix-pkgs.lib.infra.mkWranglerDeploy { 92 inherit pkgs; 93 inherit (cloudflare) itemRef accountId; 94 name = "jevstrudel-wrangler"; 95 }; 96 # The Worker's telemetry, read back from Analytics Engine's SQL API 97 # (tools/events). The same account and 1Password item as the deploy; 98 # the token is read per run and never leaves the process. 99 events = pkgs.writeShellApplication { 100 name = "jevstrudel-events"; 101 runtimeInputs = [ pkgs.nodejs ]; 102 text = '' 103 export CLOUDFLARE_ACCOUNT_ID=${cloudflare.accountId} 104 export EVENTS_OP_ITEM_REF=${pkgs.lib.escapeShellArg cloudflare.itemRef} 105 # events-schema.ts is plain data Node runs as TypeScript, as a 106 # module: worker/package.json says "type": "module". 107 exec node ${self}/tools/events/events.mjs "$@" 108 ''; 109 }; 110 # Jev's decision history for a song, from the stored performances 111 # (tools/jev-history): the working tree's local dev database, or with 112 # --remote the deployed one through the deploy's 1Password-fed 113 # wrangler, read-only. 114 jevHistory = pkgs.writeShellApplication { 115 name = "jevstrudel-jev-history"; 116 runtimeInputs = [ 117 pkgs.nodejs 118 pkgs.wrangler 119 pkgs.git 120 ]; 121 text = '' 122 export JEV_HISTORY_WRANGLER=${wranglerDeploy}/bin/jevstrudel-wrangler 123 exec node ${self}/tools/jev-history/history.mjs "$@" 124 ''; 125 }; 126 deploy = pkgs.writeShellApplication { 127 name = "jevstrudel-deploy"; 128 runtimeInputs = [ pkgs.coreutils ]; 129 text = '' 130 # Runs inside op-env-run so the Worker's key for the Jev relay is in 131 # this process's environment. (op masks secrets in anything a command 132 # prints, so `op-env-run -- printenv` would capture a placeholder.) 133 if [ -z "''${JEVSTRUDEL_DEPLOY_IN_OP:-}" ]; then 134 JEVSTRUDEL_DEPLOY_IN_OP=1 exec op-env-run -- "$0" "$@" 135 fi 136 if [ -z "''${JEVSTRUDEL_TYPESAFE_API_KEY:-}" ]; then 137 echo "jevstrudel-deploy: 1Password has no JEVSTRUDEL_TYPESAFE_API_KEY" >&2 138 exit 1 139 fi 140 # wrangler writes .wrangler/ beside its config, so it runs on a 141 # copy of worker/, with its dependencies (strudel.worker, 142 # nix/worker-closure.mjs). --env="" is production: no MCP hub. 143 # The key goes to wrangler as a secret through a pipe, not a file. 144 # every deploy carries a release note for the update prompt 145 ${pkgs.nodejs}/bin/node ${self}/tools/deploy/release-check.mjs ${strudel.static}/release-notes.json 146 work=$(mktemp -d) 147 trap 'rm -rf "$work"' EXIT 148 cp -r ${strudel.worker}/. "$work" 149 chmod -R u+w "$work" 150 # The Worker's storage before the Worker: its D1 databases and KV 151 # namespaces found (or created) by name, their ids written into 152 # the copy's wrangler.json, and the D1 migrations applied, so the 153 # code published next never meets an older schema 154 # (tools/deploy/resources.mjs). 155 ${pkgs.nodejs}/bin/node ${self}/tools/deploy/resources.mjs ${wranglerDeploy}/bin/jevstrudel-wrangler "$work" 156 ${wranglerDeploy}/bin/jevstrudel-wrangler "$work" deploy --env="" \ 157 --assets ${strudel.static} \ 158 --secrets-file <(printf 'JEVSTRUDEL_TYPESAFE_API_KEY=%s\n' "$JEVSTRUDEL_TYPESAFE_API_KEY") "$@" 159 ''; 160 }; 161 # Each song's mix as written, per part, into its SPEC.md for the art 162 # critic (tools/measure). It plays songs in the dev server and writes 163 # the working tree, so it runs the working tree's script, not a store 164 # copy. Playwright and its browsers come from one nixpkgs, so they match. 165 measure = pkgs.writeShellApplication { 166 name = "jevstrudel-measure"; 167 runtimeInputs = [ 168 pkgs.nodejs 169 pkgs.git 170 ]; 171 text = '' 172 export PLAYWRIGHT_BROWSERS_PATH=${pkgs.playwright-driver.browsers} 173 export PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=1 174 export PLAYWRIGHT_MODULE=${pkgs.playwright-test}/lib/node_modules/playwright/index.mjs 175 exec node "$(git rev-parse --show-toplevel)/tools/measure/measure.mjs" "$@" 176 ''; 177 }; 178 179 # How often listeners agree with the art critic: the site's "do you 180 # agree with Jev?" vote counts against each song's current critic.art 181 # (tools/agreement). It reads the songs from this flake's source, so a 182 # local `nix run .#agreement` uses the working tree's scores. 183 agreement = pkgs.writeShellApplication { 184 name = "jevstrudel-agreement"; 185 runtimeInputs = [ pkgs.nodejs ]; 186 text = '' 187 exec node ${self}/tools/agreement/agreement.mjs "$@" 188 ''; 189 }; 190 191 # What a vocal line says, as two speech models hear it (songs/CLAUDE.md: 192 # a sung name must survive both). whisper.cpp only, one transcription 193 # at a time on this machine: review agents each loading their own 194 # Whisper ran the host out of memory (2026-09-25). Models pinned here, 195 # from huggingface.co/ggerganov/whisper.cpp. 196 whisperModel = 197 name: sha256: 198 pkgs.fetchurl { 199 url = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-${name}.bin"; 200 inherit sha256; 201 }; 202 transcribe = pkgs.writeShellApplication { 203 name = "jevstrudel-transcribe"; 204 runtimeInputs = [ 205 pkgs.whisper-cpp 206 pkgs.ffmpeg 207 pkgs.util-linux 208 ]; 209 text = '' 210 if [ $# -eq 0 ]; then 211 echo "usage: nix run .#transcribe -- FILE... (prints each file as small.en and medium.en hear it)" >&2 212 exit 2 213 fi 214 exec 9>"''${XDG_RUNTIME_DIR:-/tmp}/jevstrudel-transcribe.lock" 215 flock 9 216 wav=$(mktemp --suffix .wav) 217 trap 'rm -f "$wav"' EXIT 218 for f in "$@"; do 219 ffmpeg -v error -y -i "$f" -ar 16000 -ac 1 "$wav" 220 for model in small.en:${whisperModel "small.en" "0p8yqkwvpl9lyy43yajk305bps0v5z1qgyg0jwh35j7cb1nqs4y6"} \ 221 medium.en:${whisperModel "medium.en" "0mj3vbvaiyk5x2ids9zlp2g94a01l4qar9w109qcg3ikg0sfjdyc"}; do 222 printf '%s\t%s\t' "$f" "''${model%%:*}" 223 whisper-cli -m "''${model#*:}" -f "$wav" -t 4 -nt -np 2>/dev/null | tr -s '\n' ' ' 224 echo 225 done 226 done 227 ''; 228 }; 229 in 230 { 231 packages.${system} = { 232 inherit (strudel) site static mcp; 233 inherit 234 vocode 235 speak 236 deploy 237 measure 238 agreement 239 events 240 jevHistory 241 ; 242 default = strudel.static; 243 # `buck2 run //:check-songs` drives a headless browser with these, 244 # from one nixpkgs so the library and its browsers always match 245 # (tools/check-songs/). 246 playwright = pkgs.playwright-test; 247 playwright-browsers = pkgs.playwright-driver.browsers; 248 }; 249 250 # `nix flake check` 251 checks.${system} = { 252 # Jev's core, the relay's rules, and every song played to its end 253 # against a stand-in Jev (nix/strudel.nix). 254 jev-tests = strudel.tests; 255 256 # The stdio MCP proxy: the dev/prod switch and forwarding (tools/mcp/). 257 mcp-tests = strudel.mcpTests; 258 259 # The Worker's TypeScript against the Workers runtime types. 260 # wrangler bundles with esbuild, which strips types without checking 261 # them. `wrangler types` generates the runtime types for 262 # worker/wrangler.json's compatibility_date from the workerd it 263 # ships, offline; env.ts is the Env, hand-written because HUB exists 264 # only in `--env dev`. It checks the Worker the deploy uploads, with 265 # its dependencies' types (strudel.worker). 266 worker-types = 267 pkgs.runCommand "jevstrudel-worker-types" 268 { 269 nativeBuildInputs = [ 270 pkgs.wrangler 271 pkgs.typescript 272 ]; 273 } 274 '' 275 cp -r ${strudel.worker} worker 276 chmod -R u+w worker 277 cd worker 278 export HOME=$TMPDIR WRANGLER_SEND_METRICS=false 279 wrangler types worker-configuration.d.ts --include-env=false >/dev/null 280 tsc -p . 281 touch $out 282 ''; 283 }; 284 285 apps.${system} = { 286 deploy = { 287 type = "app"; 288 program = "${deploy}/bin/jevstrudel-deploy"; 289 }; 290 measure = { 291 type = "app"; 292 program = "${measure}/bin/jevstrudel-measure"; 293 }; 294 agreement = { 295 type = "app"; 296 program = "${agreement}/bin/jevstrudel-agreement"; 297 }; 298 events = { 299 type = "app"; 300 program = "${events}/bin/jevstrudel-events"; 301 }; 302 jev-history = { 303 type = "app"; 304 program = "${jevHistory}/bin/jevstrudel-jev-history"; 305 }; 306 transcribe = { 307 type = "app"; 308 program = "${transcribe}/bin/jevstrudel-transcribe"; 309 }; 310 }; 311 312 devShells.${system}.default = pkgs.mkShell { 313 packages = [ 314 vocode 315 speak 316 # cutting and encoding vocal samples 317 pkgs.ffmpeg 318 # the dev server: `buck2 run //:dev` (see BUCK) 319 pkgs.buck2 320 pkgs.wrangler 321 # `tsc -p worker`, after `wrangler types` in worker/ 322 pkgs.typescript 323 pkgs.nodejs 324 pkgs.pnpm_10 325 ]; 326 }; 327 }; 328}