jevstrudel.git / website / src / jev / CLAUDE.md
CLAUDE.mdpreviewCLAUDE.mdsource57 lines · 11.3 KB · raw
1@README.md
2
3## Notes for agents
4
5**`songs.mjs` runs at build time only.** Its `import.meta.glob` paths are relative to this file and reach the repo-root `songs/`; the browser never imports it. Anything the welcome tab needs must be in the JSON `loadSongs()` returns.
6
7**Untrusted code plays only in the sandbox; never `setCode` it into the page.** A listener's song and code from a link are someone else's, and evaluating them in the page would run them on the site's origin with the visitor's session. Load them with `showForeign`/`loadListenerSong` (which mark the editor foreign), never `editor.setCode` — a listener song carries its code as `foreignCode`, not `code`, exactly so no path that plays a site song can pick it up. `guardEditor` (sandbox.mjs) makes `editor.evaluate`/`toggle`/block-eval on foreign code go to the sandbox and the page's own `repl.evaluate` refuse it. Trusted loaders (a site song, a pattern, an example, the dev hub's MCP `play`, which is the repo's own Claude on localhost) call `setOwnCode`, which returns the editor to the page's own. The hosted MCP's `play` (`myTabs.mjs`) is not trusted: it is `showForeign(…, { ai })`, and never `setOwnCode`. The MCPs' `play-song` (`tabTools.mjs`) reaches the page only with a site song found by id in `readSongs()`, this page's own build, exactly as its card; a listener's song it plays through `loadListenerSong`, foreign. Keep `tabTools.mjs` taking ids and data, never code. The boundary is the opaque origin, not the name denylist (worker/README.md, "A listener's song runs in a sandbox").
8
9**Whose budget a frame's Jev calls spend is fixed when the frame is made** (`charge`, from its provenance: only `{ ai }` is charged, to the listener who connected that app). Provenance changing destroys the frame, so a listener's song can never inherit a charged frame. Keep it that way; never decide per call.
10
11**Everything from the sandbox is untrusted data.** The frame runs a listener's code, so every `postMessage` it sends is rebuilt by `sandboxProtocol.mjs` (`fromFrame`) from typed, bounded fields before the page reads it, and its log text is escaped before the console (which renders HTML). Never pass a frame message's field to `dangerouslySetInnerHTML`, `eval`, or on as-is; add a new field to the protocol's allowlist, with a bound, and a test in `sandboxProtocol.test.mjs`.
12
13**The sandbox page needs `<base href="/">` and its CSP allows it.** Songs load samples from a root-relative URL (`samples('jev-samples/strudel.json')`); the page is at `/jev/sandbox/`, so without the base the URL would resolve under that path. `base-uri 'self'` in the CSP (sandboxPolicy.mjs) is what lets the `<base>` tag take effect — `'none'` silently blocks it, and samples 404 (2026-09-26). AudioWorklets need `worker-src 'self' blob: data:` (the meter is a blob, superdough's dough worklet a data: URL in dev).
14
15**`init`'s deferred load must not clobber a chosen song.** `useReplContext`'s `initCode().then(...)` runs after an await, by which point the visitor may have played a song (a card rewrites the URL to `?listener=`). It returns early if `editor.code !== initialCode`, or it would tear down a listener song already playing in its sandbox (2026-09-26).
16
17**Playing a song stops first.** `WelcomeTab` awaits `editor.stop()` before `setCode`/`evaluate` because songs count cycles from 0, and only the scheduler's `stop()` resets the count (`packages/core/cyclist.mjs`); `pause()` does not.
18
19**Evaluations run one at a time, and the newest wins** (`packages/core/repl.mjs`, `serialized`). One waiting in `beforeEval` (the welcome song waits there for the first click) holds the next until it settles, and a superseded one stops at its next await without setting its pattern or reaching `afterEval`, so the booth's one draft (`boothStore.beginEval`/`commitEval`) only ever holds one evaluation's views. A `stop()` or newer `start()` during `beforeStart` cancels that start (`cyclist.mjs`). The corollary: nothing awaited in `beforeEval`/`beforeStart` may wait forever, or every later evaluation waits with it; a guest's wait for the host's clock ends on leave and on stop (`releaseClockWaiters`, party.mjs). Overlapping, a first click on a card played the welcome song instead, with an empty booth and no autoplay (2026-09-27).
20
21**Every query of the pattern moves a jev()'s playhead,** the Drawer's frames included, so nothing may query a song at a cycle it is not at. Strudel's Drawer kept `lastFrame` from the song before and queried the next song from that song's end: after autoplay every section counted as begun and played its fallbacks ("answered after the section began … asked at cycle 67.5"). `packages/draw/draw.mjs` now resets it on start and never queries backwards (2026-09-27).
22
23**Jev's text goes in single quotes.** The transpiler turns double-quoted AND backtick strings in REPL code into mini-notation, so `choice("Pick a move", …)` arrives as a Pattern; `jevCore.mjs` throws at evaluation saying so. Backticks were assumed safe and were not (2026-09-24).
24
25**Never block inside a query.** Queries run on the scheduler's timer; a modal or a synchronous wait there stalls audio. Jev calls start from a query but are never awaited in it.
26
27**A Jev answer is untrusted until checked.** `jevCore.mjs` accepts only an own key of the choice's criteria (`Object.hasOwn`), so neither a stray label nor `"toString"` can select anything; everything else plays the fallback and logs why.
28
29**The booth UI shows the last successful evaluation.** `booth()` only drafts its view into `boothStore`; `useReplContext`'s `afterEval` commits it. Committing from `booth()` itself would show a booth whose code failed to evaluate while the previous song keeps playing.
30
31**A new deploy waits for the visitor.** The service worker is in prompt mode (`astro.config.mjs`), and `src/pwa.ts` switches to a waiting deploy by messaging the worker itself. Don't go back to registerSW's `updateSW(true)`: it reloads only on a Workbox 'controlling' event flagged as an update, which a deploy found by the periodic check is not, so the button did nothing (2026-09-24). `release-notes.json` stays out of the precache so the modal reads it live.
32
33**`form` keeps the written order playable.** Every section's `next` must include the section after it as written: that is the fallback when Jev can't answer, and `validate` refuses a form without it or without an ending.
34
35**`jev()` is TypeSafe's request and nothing else.** It takes `state` and `questions` only, built with the SDK's `choice`/`score`/`noul` (same signatures as `@typesafe-ai/sdk`). Anything Jev does not know (when to ask, fallbacks, a form) is Strudel's: `.every(cycles, { fallback })` and `walk(...)`. Don't add options to `jev()`; the user rejected the earlier config-object API (`.jev({ every, about, form, intensity, moves })`) for hiding Jev behind an API of our own (2026-09-24).
36
37**Accounts are optional, always.** Anonymous use must stay exactly as it is: no feature may require signing in except the account's own (its budget, its radio history). `account.mjs` treats a missing or failing `/jev/auth/me` as signed out, so a static host without the Worker still works.
38
39**`ask.mjs` must not import `account.mjs`.** The budget reaches the header through `onBudget` listeners, and `account.mjs` resets the back-off (`resetBackoff`) when who is signed in changes, since a back-off earned against one account's budget or the address's rate limit does not apply to another. `ask.mjs` is also loaded by the Node tests, which should not pull in the browser's WebAuthn library.
40
41**A party guest's start is set in `partyStarted`, last in `beforeStart`.** The cyclist's first tick begins at `scheduler.lastEnd` (`packages/core/cyclist.mjs`; `stop()` zeroes it), so a guest sets it to the host's cycle right before the first tick. Anything awaited after `partyStarted` in `beforeStart` delays the start without moving that cycle, and the guest falls behind by exactly that long. Keep it last.
42
43**Follow mode's wire is jevCore's, not the party's.** `shareDecision`, `watchDecisions` and `followDecisions` know nothing of rooms or sockets. A view's wire name is its declaration order, the same key replay uses (`view.index`), and a shared decision's `status`, `values` and `from` are a recorded segment's fields, so a party's decisions are a performance as `setReplay` reads one. The difference is timing: replay has every segment before the song starts and settles inside `request()`; follow mode's arrive while it plays, so they go through `receive()` and can be late. Only the same code keeps the declaration order, which is why a guest plays the song from its own build by id and never code the host sends.
44
45**The lobby shows other people's tabs as data, and plays nothing of theirs.** Everything in `lobbyStore.tabs` is another page's report (checked by `worker/src/lobby-room.ts`, then cut to known fields by `tabOf`): render it as text only. "Listen along" only ever joins a listening party (`joinParty`), which plays a site song from this page's own build by id; "play it" plays a site song found in `readSongs()`; a listener's song is a `/?listener=` link, which loads it foreign, into the sandbox. Never let a lobby message carry code, and never let one start playback in this page by any other path. A tab asked to be listened to starts a party only on its own site song (`invite`), and sends its room once it is in it, because the room turns away a guest who arrives first.
46
47**A host's re-evaluation mid-song is not shared.** Follow mode is set on the views of the evaluation that starts (`partyBeforeStart`); a host that re-evaluates while playing gets new views that are not watched, so guests keep playing their fallbacks until the host's next start. Same for a host who edits the song: guests hear it as published (the page says so).
48
49**`jev.mjs` exports are the REPL's globals.** `repl/util.mjs` evaluates every export into song scope, so export only what songs call; test hooks live in `jevCore.mjs`.
50
51**A replay asks Jev nothing, ever.** In replay (`setReplay`) `request()` still computes the form's allowed sections and `allow`'s options, then settles from the recording instead of calling `decide`; it never falls through to the relay, even for a segment the recording lacks (that plays the fallbacks). `jevCore.test.mjs` and `performance.test.mjs` count calls to prove it. Recorded values are the ones that played (after sampling), so a replay never samples again.
52
53**A listener song's take is recorded and replayed through the sandbox only.** The recorder reads the frame's booth (`sandboxed`, its `jevs` rebuilt by `sandboxProtocol.mjs`; a booth whose `jevs` were refused is not recorded) and the song's code from `sandbox.foreign().song`, never from the frame. Its replay reaches the frame as the `play` message's `replay` (`cleanReplay`), armed by `loadListenerSong` for that song id only: never `setReplay` it in the page, and never let a replay armed for one song reach another's play (`replayFor`).
54
55**Jevs in a performance are matched by declaration order** (`view.index`, from `boothStore.adopt`). Keep `adopt` returning the draft index; a song's jev()s must share one cadence to be recorded (`performanceOf` returns null otherwise).
56
57**`listenersSoFar` names only the song's own sections.** The tally is anyone's writing (the Worker checks only a name's shape), so jevCore keeps names the form has (or section numbers, without a form) and bounded counts, at most `LISTENERS_MAX`.