jevstrudel.git / flake.nix
flake.nixannotatedflake.nixsource328 lines · 13.4 KB · raw
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; };

Vocal tooling: speak renders text with a Windows SAPI voice (so it needs the host's windows-exec, not packaged here), and vocode makes 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);
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}