lmjtfy.git / apps / lmjtfy / src / lib.rs
lib.rsannotatedlib.rssource891 lines · 39.7 KB · raw
1//! lmjtfy, the Worker: the page, the stream that answers a question, and the
2//! gate that runs as the visitor types.
3//!
4//! `POST /ask` answers with Datastar events. Each one is the whole transcript
5//! as it stands, so the browser holds no state: it morphs what it is sent.
6//!
7//! What happens to an input is decided by rules (`rules::RULES`). First Jev
8//! is asked, in one request, for every fact the rules want: can it judge the
9//! input, what kind of question is it, is it several, and its answer if it
10//! is one yes-or-no question. That alone ends most queries: a refusal, or a
11//! direct answer. Only a question that needs options or a scale written, or
12//! splitting up, goes to the LLM (Workers AI), and then Jev answers what it
13//! wrote, again in one request. Jev costs a hundredth of what the LLM does.
14//!
15//! No call is sent from here. Each goes to the archive
16//! (`archive.rs`), which returns the response it already holds for that
17//! exact request, or makes the call inside the day's budget and keeps it.
18
19use std::convert::Infallible;
20use std::time::Duration;
21
22use ::archive::{Ask, Called, Pot};
23use ::card::Card;
24use ask::{Judged, Kept, Outcome, Prepared, Wanted};
25use axum::Router;
26use axum::body::Body;
27use axum::extract::{Extension, Json, Path, Query, State};
28use axum::http::{HeaderMap, Response, header};
29use axum::routing::{get, post};
30use datastar::prelude::{ElementPatchMode, PatchElements, PatchSignals};
31use futures_channel::mpsc::{UnboundedSender, unbounded};
32use futures_util::StreamExt;
33use jev_client::Client;
34use jev_protocol::ModelId;
35use jev_worker::{FetchTransport, WorkerRuntime};
36use llm::Model;
37use rules::{Effect, End, Fact, Network, Next, Note, Value, Want};
38use serde::Deserialize;
39use tower_service::Service;
40use worker::{Context, Env, HttpRequest, event};
41
42mod ai;
43pub mod archive;
44mod browse;
45pub mod clone;
46mod diagram;
47pub mod events;
48pub mod meter;
49mod object;
50mod playground;
51pub mod view;
52
53use view::{Ending, JevCall, LlmCall, LlmOutcome, Tool, View};
54
55const DATASTAR: &str = include_str!("../../../ds-bundle/datastar.js");
56/// The pixel emoji the site draws (the online list's flags, the vote
57/// buttons): SerenityOS's, cut down to those (third-party/serenity-emoji).
58const EMOJI: &[u8] = include_bytes!("../../../third-party/serenity-emoji/emoji.woff2");
59
60/// The commit this Worker was built from (`build.rs`). Pages carry it, and the
61/// archive tells every page that connects what it is now.
62pub const BUILD: &str = env!("LMJTFY_BUILD");
63/// What this deploy changed, for the people on the site: the built commit's
64/// `Release-Note:` trailer, or empty (`build.rs`).
65pub const NOTE: &str = env!("LMJTFY_NOTE");
66
67/// The secret's name: lmjtfy's own Jev key, apart from the estate's general
68/// one, as jevstrudel has its own.
69const KEY: &str = "LMJTFY_TYPESAFE_API_KEY";
70const JEV_MODEL: &str = "JEV_MODEL";
71const LLM_MODEL: &str = "LLM_MODEL";
72const JEV_DOLLARS_PER_DAY: &str = "JEV_DOLLARS_PER_DAY";
73/// What one visitor may do in a minute: full answers, and the facts request
74/// that runs as they type. Counted by the budget object (`meter::admit`).
75/// Cloudflare's own rate limiting binding was tried first and never refused
76/// a request on the live site (2026-10-02: 45 asks in 40 seconds, limit 10).
77const ASKS_PER_MINUTE: u32 = 10;
78const GLANCES_PER_MINUTE: u32 = 60;
79
80#[derive(Clone)]
81struct App {
82    env: Env,
83}
84
85#[event(fetch)]
86async fn fetch(mut request: HttpRequest, env: Env, context: Context) -> worker::Result<Response<Body>> {
87    let mut router = Router::new()
88        .route("/", get(page))
89        .route("/datastar.js", get(datastar))
90        .route("/emoji.woff2", get(emoji))
91        .route("/live.js", get(live_js))
92        .route("/icons/{name}", get(icon))
93        .route("/card.png", get(card))
94        .route("/rules", get(rules_page))
95        .route("/rules.svg", get(rules_svg))
96        .route("/ask", post(ask))
97        .route("/gate", post(gate))
98        .route("/rate", post(rate))
99        .route("/seen", post(seen))
100        .route("/feed", get(feed))
101        .route("/live", get(live))
102        .route("/{repo}", get(clone::landing))
103        .route("/{repo}/", get(clone::landing))
104        .route("/{repo}/{*path}", get(browse::path))
105        .route("/{repo}/info/refs", get(clone::refs))
106        .route("/{repo}/git-upload-pack", post(clone::upload_pack))
107        .with_state(App { env: env.clone() });
108    // The request as an event, before it is known what came of it
109    // (events.rs). A GET's is kept here, once it has a status, after the
110    // response has gone; a POST's by its handler, which knows how it ended.
111    let headers = request.headers().clone();
112    let event = events::of(&request);
113    if let Some(to) = moved(&request) {
114        let mut event = event.named("moved").with(headers.get(header::HOST).and_then(|host| host.to_str().ok()).unwrap_or_default());
115        event.status = 301.0;
116        context.wait_until(async move { events::record(&env, event).await });
117        return Ok(Response::builder()
118            .status(axum::http::StatusCode::MOVED_PERMANENTLY)
119            .header(header::LOCATION, to)
120            .header(header::CACHE_CONTROL, "public, max-age=86400")
121            .body(Body::empty())
122            .expect("static headers are valid"));
123    }
124    let got = event.clone().got();
125    request.extensions_mut().insert(event);
126    let mut response = router.call(request).await?;
127    if let Some(mut event) = got {
128        event.status = f64::from(response.status().as_u16());
129        // A page given to a browser is a visit, and says when it was counted.
130        let page = response.status().is_success() && response.headers().get(header::CONTENT_TYPE).is_some_and(|kind| kind.as_bytes().starts_with(b"text/html"));
131        if page
132            && let Some(cookie) = event.visit(&headers, js_sys::Date::now()).and_then(|cookie| axum::http::HeaderValue::from_str(&cookie).ok())
133        {
134            response.headers_mut().append(header::SET_COOKIE, cookie);
135        }
136        // Any page a browser is first given gives it its id, before it can
137        // ask anything, and that page's own event is the id's first: until
138        // 2026-10-03 only the home page gave one, and never said which.
139        if page
140            && event.browser.is_empty()
141            && event.client != "bot"
142            && let Some((id, cookie)) = new_browser()
143            && let Ok(cookie) = axum::http::HeaderValue::from_str(&cookie)
144        {
145            response.headers_mut().append(header::SET_COOKIE, cookie);
146            event.browser = id;
147        }
148        context.wait_until(async move { events::record(&env, event).await });
149    }
150    Ok(response)
151}
152
153/// The site's address, bare. The addresses in `ELSEWHERE` lead to it: where
154/// it was first served, and the same name with `www`.
155const HOME: &str = "lmjtfy.fun";
156const ELSEWHERE: [&str; 2] = ["lmjtfy.deizel.workers.dev", "www.lmjtfy.fun"];
157
158/// Where a request to another of the site's addresses should go instead,
159/// for good: the same path and query at `HOME`. People, link previews and git are sent on (git
160/// follows the redirect of its first request and clones from the new
161/// address). A page still open on the old address is left to finish there:
162/// its socket and its own requests (which a browser marks with
163/// `Sec-Fetch-Mode` other than `navigate`) would only break if sent on, and
164/// it moves the next time it is loaded.
165fn moved(request: &HttpRequest) -> Option<String> {
166    let host = request.headers().get(header::HOST)?.to_str().ok()?;
167    if !ELSEWHERE.contains(&host) {
168        return None;
169    }
170    let get = matches!(*request.method(), axum::http::Method::GET | axum::http::Method::HEAD);
171    let socket = request.headers().contains_key(header::UPGRADE);
172    let own = request.headers().get("sec-fetch-mode").is_some_and(|mode| mode != "navigate");
173    if !get || socket || own {
174        return None;
175    }
176    let path = request.uri().path_and_query().map_or("/", |path| path.as_str());
177    Some(format!("https://{HOME}{path}"))
178}
179
180#[derive(Deserialize)]
181struct Asked {
182    #[serde(default)]
183    q: String,
184    /// Which version of each call the page wants (`archive::Pins`).
185    #[serde(default)]
186    pins: String,
187}
188
189/// What `/card.png` is of: a question, or with `code`, the code.
190#[derive(Deserialize)]
191struct Pictured {
192    #[serde(default)]
193    q: String,
194    code: Option<String>,
195    /// With `code`, which repository: `lmjtfy.git` when absent.
196    repo: Option<String>,
197}
198
199/// `#[worker::send]`: asking the budget object is not `Send`, and an axum
200/// handler's future must be.
201#[worker::send]
202async fn page(State(app): State<App>, headers: HeaderMap, Query(asked): Query<Asked>) -> Response<Body> {
203    let budget = shared(&app.env).await;
204    let home = archive::home(&app.env).await;
205    let typed = ask::clean(&asked.q);
206    // For the link preview: what Jev said, if this was asked before.
207    let said = if typed.is_empty() { None } else { archive::answer(&app.env, &typed).await };
208    let page = view::page(&typed, said.as_ref(), &origin(&headers), budget, home.as_ref());
209    respond("text/html; charset=utf-8", "no-cache", page.into_string().into())
210}
211
212/// Where this site is, for the preview's image address, which has to be
213/// whole. From the request, so the dev server previews itself.
214fn origin(headers: &HeaderMap) -> String {
215    let host = headers.get(header::HOST).and_then(|value| value.to_str().ok()).unwrap_or("localhost");
216    let scheme = if host.starts_with("localhost") || host.starts_with("127.") { "http" } else { "https" };
217    format!("{scheme}://{host}")
218}
219
220/// The picture a link unfurls with: the question and what Jev said, read
221/// from the archive. It asks nothing, so a preview bot spends nothing.
222#[worker::send]
223async fn card(State(app): State<App>, headers: HeaderMap, Query(asked): Query<Pictured>) -> Response<Body> {
224    // `?code=<commit>`: the code page's picture. The commit is only there to
225    // change the address; what is drawn is the latest one read.
226    if asked.code.is_some() {
227        let repo = asked.repo.as_deref().and_then(clone::Repo::named).unwrap_or(clone::Repo::Lmjtfy);
228        let latest = clone::latest(&app.env, repo).await;
229        let url = format!("{}/{}", origin(&headers), repo.served());
230        let card = Card::Code { name: repo.served(), url: &url, recurse: !repo.submodules().is_empty(), about: repo.blurb(), branch: repo.branch(), latest: latest.as_ref() };
231        return respond("image/png", "public, max-age=3600", ::card::png(card).into());
232    }
233    let typed = ask::clean(&asked.q);
234    let said = if typed.is_empty() { None } else { archive::answer(&app.env, &typed).await };
235    let said = said.map(|entry| entry.answers).unwrap_or_default();
236    let card = if typed.is_empty() { Card::Home } else { Card::Question { input: &typed, said: &said } };
237    respond("image/png", "public, max-age=3600", ::card::png(card).into())
238}
239
240/// `/live`: a page's socket, handed to the archive, which keeps it and
241/// tells it how many pages are open and what happens while it is.
242#[worker::send]
243async fn live(State(app): State<App>, mut request: axum::extract::Request) -> Response<Body> {
244    // Where Cloudflare placed the page, for the online list. The Worker sets
245    // these headers itself, over any the page sent, so a page cannot name its
246    // own place; the archive keeps them on the socket while it is open.
247    // Only a socket is handed on: the archive reads any other request as a
248    // message from this Worker, and a visitor's must never be one.
249    if !request.headers().get(header::UPGRADE).is_some_and(|upgrade| upgrade.as_bytes().eq_ignore_ascii_case(b"websocket")) {
250        return clone::refused(axum::http::StatusCode::UPGRADE_REQUIRED, "Not a socket.");
251    }
252    let place = request.extensions().get::<worker::Cf>().map(|cf| (cf.country(), cf.city()));
253    // Who connected, for the archive to keep (`events`).
254    let event = request.extensions().get::<events::Event>().and_then(|event| serde_json::to_string(event).ok());
255    let headers = request.headers_mut();
256    headers.remove(archive::COUNTRY);
257    headers.remove(archive::CITY);
258    headers.remove(archive::EVENT);
259    if let Some(event) = event.and_then(|event| axum::http::HeaderValue::from_str(&view::url_encoded(&event)).ok()) {
260        headers.insert(archive::EVENT, event);
261    }
262    headers.insert(archive::PLACED, axum::http::HeaderValue::from_static("1"));
263    if let Some((country, city)) = place {
264        for (name, value) in [(archive::COUNTRY, country), (archive::CITY, city)] {
265            if let Some(value) = value.and_then(|value| axum::http::HeaderValue::from_str(&view::url_encoded(&value)).ok()) {
266                headers.insert(name, value);
267            }
268        }
269    }
270    let request = match worker::Request::try_from(request) {
271        Ok(request) => request,
272        Err(_) => return clone::refused(axum::http::StatusCode::BAD_REQUEST, "Not a socket."),
273    };
274    match archive::live(&app.env, request).await {
275        Ok(response) => response.into(),
276        Err(_) => clone::refused(axum::http::StatusCode::BAD_GATEWAY, "The archive is unreachable."),
277    }
278}
279
280/// What is left of the day's shared budgets, for the page.
281async fn shared(env: &Env) -> Option<view::Shared> {
282    let status = meter::status(env).await?;
283    let jev_per_day = env.var(JEV_DOLLARS_PER_DAY).ok()?.to_string().parse().ok()?;
284    Some(view::Shared { status, neurons_per_day: llm::FREE_NEURONS_PER_DAY, jev_dollars_per_day: jev_per_day })
285}
286
287/// The rules as an SVG file, for the READMEs.
288async fn rules_svg() -> Response<Body> {
289    respond("image/svg+xml", "public, max-age=3600", diagram::standalone(&Network::lmjtfy()).into())
290}
291
292async fn datastar() -> Response<Body> {
293    respond("text/javascript; charset=utf-8", "public, max-age=86400", DATASTAR.into())
294}
295
296/// The SharedWorker that holds one `/live` socket for a browser's tabs. Its
297/// address carries the build, so a deploy's pages get a new one; the script
298/// itself does not change with it, so it may be kept.
299async fn live_js() -> Response<Body> {
300    respond("text/javascript; charset=utf-8", "public, max-age=3600", include_str!("live.js").into())
301}
302
303mod pictures {
304    include!(concat!(env!("OUT_DIR"), "/icons.rs"));
305}
306
307/// `/icons/<name>.png`: one of the explorer's pixel icons
308/// (third-party/jerrys-pixel-icons), from the table `build.rs` writes. They
309/// do not change under a name, so a browser may keep one for good.
310async fn icon(Path(name): Path<String>) -> Response<Body> {
311    let found = name.strip_suffix(".png").and_then(|name| pictures::ICONS.binary_search_by_key(&name, |(icon, _)| *icon).ok());
312    match found {
313        Some(at) => respond("image/png", "public, max-age=31536000, immutable", Body::from(pictures::ICONS[at].1)),
314        None => clone::refused(axum::http::StatusCode::NOT_FOUND, "No such icon."),
315    }
316}
317
318async fn emoji() -> Response<Body> {
319    respond("font/woff2", "public, max-age=604800", Body::from(EMOJI))
320}
321
322/// The rules with the facts the link sets. Nothing is asked of anyone.
323async fn rules_page(Query(params): Query<Vec<(String, String)>>) -> Response<Body> {
324    let network = Network::lmjtfy();
325    let play = playground::play(&network, &params);
326    let says = playground::says(&network, &play);
327    let page = view::playground(&network, &play.known, &play.fired, &says, |clicked| playground::link(&play.known, clicked));
328    respond("text/html; charset=utf-8", "no-cache", page.into_string().into())
329}
330
331/// Who is asking, for the visitor limit: Cloudflare's own header for the
332/// visitor's address, a key in the budget object's memory for a minute.
333fn visitor(headers: &HeaderMap) -> String {
334    headers.get("cf-connecting-ip").and_then(|value| value.to_str().ok()).unwrap_or("unknown").to_owned()
335}
336
337/// Datastar posts the page's signals as JSON; `q` is the search box.
338async fn ask(State(app): State<App>, Extension(event): Extension<events::Event>, headers: HeaderMap, Json(asked): Json<Asked>) -> Response<Body> {
339    let visitor = visitor(&headers);
340    let browser = browser(&headers);
341    let pins = ::archive::Pins::parse(&asked.pins);
342    stream(move |events| answer(app, asked.q, pins, visitor, browser, event.named("answer"), events))
343}
344
345/// A vote on Jev's answer: the search box still holds the question, and
346/// the button sets which way.
347#[derive(Deserialize)]
348struct Rated {
349    #[serde(default)]
350    q: String,
351    vote: String,
352}
353
354/// How many votes a visitor may cast a minute.
355const VOTES_PER_MINUTE: u32 = 30;
356
357/// `/rate`: a browser's vote on Jev's answer, and the votes after it, as the
358/// rating element. A browser with no id cannot vote; a question never
359/// answered has nothing to vote on.
360#[worker::send]
361async fn rate(State(app): State<App>, Extension(event): Extension<events::Event>, headers: HeaderMap, Json(rated): Json<Rated>) -> Response<Body> {
362    let input = ask::clean(&rated.q);
363    let browser = browser(&headers);
364    let vote = match rated.vote.as_str() {
365        "up" => Some(::archive::Vote::Up),
366        "down" => Some(::archive::Vote::Down),
367        _ => None,
368    };
369    let rating = match (&browser, vote) {
370        (Some(browser), Some(vote)) if !input.is_empty() && meter::admit(&app.env, "rate", visitor(&headers), VOTES_PER_MINUTE).await => {
371            archive::rate(&app.env, &input, browser, vote).await
372        }
373        _ => archive::rating(&app.env, &input, browser.as_deref().map(|browser| asker(browser, &input))).await,
374    };
375    events::record(&app.env, event.named("vote").with(rated.vote.chars().take(8).collect::<String>()).about(&input)).await;
376    let patch = PatchElements::new(view::rating(rating.as_ref(), browser.is_some()).into_string()).into_datastar_event();
377    respond("text/event-stream", "no-cache", Body::from(patch.to_string()))
378}
379
380/// How many reports a visitor's pages may make a minute.
381const REPORTS_PER_MINUTE: u32 = 120;
382
383/// `/seen`: a page's report of itself as it is left, or as a link off the
384/// site is followed (`page.js`): how long it was in view, how far down, the
385/// screen. Kept as a `read` or `out` event; the page is told nothing.
386#[worker::send]
387async fn seen(State(app): State<App>, Extension(event): Extension<events::Event>, headers: HeaderMap, body: String) -> Response<Body> {
388    if let Ok(report) = serde_json::from_str::<::archive::Report>(&body)
389        && meter::admit(&app.env, "seen", visitor(&headers), REPORTS_PER_MINUTE).await
390    {
391        events::record(&app.env, event.seen(&report)).await;
392    }
393    Response::builder().status(axum::http::StatusCode::NO_CONTENT).body(Body::empty()).expect("no headers to be wrong")
394}
395
396/// The cookie that tells one browser from another, for counting each once
397/// per question. A random id the Worker gives a browser on its first page,
398/// kept a year. `askers` and `ratings` get only `asker` of it; `events`
399/// keeps it as it is, which is what ties one browser's rows together.
400const BROWSER: &str = "lmjtfy_browser";
401
402/// This browser's id, if it has one.
403fn browser(headers: &HeaderMap) -> Option<String> {
404    let cookies = headers.get(header::COOKIE)?.to_str().ok()?;
405    cookies
406        .split(';')
407        .filter_map(|pair| pair.trim().split_once('='))
408        .find(|(name, _)| *name == BROWSER)
409        .map(|(_, id)| id.to_owned())
410        .filter(|id| id.len() == 36 && id.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-'))
411}
412
413/// A new browser id, as a `Set-Cookie` value, for a browser that has none.
414fn new_browser() -> Option<(String, String)> {
415    use wasm_bindgen::JsCast;
416    let crypto = js_sys::Reflect::get(&js_sys::global(), &"crypto".into()).ok()?;
417    let uuid = js_sys::Reflect::get(&crypto, &"randomUUID".into()).ok()?.dyn_into::<js_sys::Function>().ok()?.call0(&crypto).ok()?.as_string()?;
418    let cookie = format!("{BROWSER}={uuid}; Max-Age=31536000; Path=/; HttpOnly; Secure; SameSite=Lax");
419    Some((uuid, cookie))
420}
421
422/// The browser for one question: its id and the question, hashed together,
423/// so the archive can tell a browser asking again from a new one and can link
424/// nothing else.
425pub(crate) fn asker(browser: &str, input: &str) -> ::archive::Asker {
426    use sha2::{Digest, Sha256};
427    let digest = Sha256::new().chain_update(browser.as_bytes()).chain_update([0]).chain_update(input.as_bytes()).finalize();
428    ::archive::Asker(digest.iter().map(|byte| format!("{byte:02x}")).collect())
429}
430
431/// Where "asked lately" was scrolled to: the last line shown.
432#[derive(Deserialize)]
433struct Scrolled {
434    ms: f64,
435    q: String,
436}
437
438/// `/feed`: the page of "asked lately" after the last line shown, put before
439/// the feed's end, and a new end. It reads the archive and asks nothing.
440#[worker::send]
441async fn feed(State(app): State<App>, Query(scrolled): Query<Scrolled>) -> Response<Body> {
442    let after = ::archive::Cursor { asked_ms: scrolled.ms, input: scrolled.q };
443    // An archive that cannot be read ends the feed here, rather than asking
444    // again for as long as the end is in view.
445    let entries = archive::older(&app.env, after).await.unwrap_or_default();
446    let (lines, end) = view::older(&entries);
447    let end = PatchElements::new(end.into_string()).selector("#more").mode(ElementPatchMode::Replace);
448    let mut body = String::new();
449    if !entries.is_empty() {
450        let lines = PatchElements::new(lines.into_string()).selector("#more").mode(ElementPatchMode::Before);
451        body.push_str(&lines.into_datastar_event().to_string());
452    }
453    body.push_str(&end.into_datastar_event().to_string());
454    respond("text/event-stream", "no-cache", Body::from(body))
455}
456
457/// The same signals, while the visitor is still typing: the facts alone.
458async fn gate(State(app): State<App>, Extension(event): Extension<events::Event>, headers: HeaderMap, Json(asked): Json<Asked>) -> Response<Body> {
459    let visitor = visitor(&headers);
460    stream(move |events| glance(app, asked.q, visitor, event.named("gate"), events))
461}
462
463/// An event stream fed by `work`. Jev and Workers AI calls are not `Send`
464/// (they hold JS values) and an axum handler must be, so the work runs on the
465/// isolate's own executor and reaches the response through a channel. The
466/// open response body is what keeps the request alive while it runs.
467fn stream<W, F>(work: W) -> Response<Body>
468where
469    W: FnOnce(UnboundedSender<String>) -> F,
470    F: Future<Output = ()> + 'static,
471{
472    let (events, stream) = unbounded::<String>();
473    wasm_bindgen_futures::spawn_local(work(events));
474    respond("text/event-stream", "no-cache", Body::from_stream(stream.map(Ok::<_, Infallible>)))
475}
476
477fn respond(content_type: &'static str, cache: &'static str, body: Body) -> Response<Body> {
478    Response::builder()
479        .header(header::CONTENT_TYPE, content_type)
480        .header(header::CACHE_CONTROL, cache)
481        .body(body)
482        .expect("static headers are valid")
483}
484
485/// Sends markup to morph into the page. A closed channel means the visitor
486/// left; there is nobody to tell.
487fn patch(events: &UnboundedSender<String>, markup: maud::Markup) {
488    let event = PatchElements::new(markup.into_string()).into_datastar_event();
489    let _ = events.unbounded_send(event.to_string());
490}
491
492fn show(events: &UnboundedSender<String>, view: &View) {
493    patch(events, view::transcript(view));
494}
495
496/// Everything a Jev call needs, built once per request.
497pub(crate) struct Jev {
498    pub(crate) client: Client<FetchTransport, WorkerRuntime>,
499    pub(crate) model: ModelId,
500    pub(crate) dollars_per_day: f64,
501}
502
503/// Why no Jev call can be made at all.
504pub(crate) enum NoJev {
505    /// The Worker has no API key.
506    Offline,
507    Broken(String),
508}
509
510impl From<NoJev> for Ending {
511    fn from(none: NoJev) -> Self {
512        match none {
513            NoJev::Offline => Ending::Offline,
514            NoJev::Broken(error) => Ending::Failed { error },
515        }
516    }
517}
518
519/// The Jev client. The Worker builds one to prepare requests and to know the
520/// site is online; the archive builds one to send them.
521pub(crate) fn jev(env: &Env) -> Result<Jev, NoJev> {
522    let key = env.secret(KEY).map(|secret| secret.to_string()).unwrap_or_default();
523    if key.trim().is_empty() {
524        return Err(NoJev::Offline);
525    }
526    let var = |name: &str| env.var(name).map(|var| var.to_string()).map_err(|e| NoJev::Broken(format!("{name}: {e}")));
527    let model = ModelId::pinned(&var(JEV_MODEL)?).map_err(|e| NoJev::Broken(e.to_string()))?;
528    let dollars_per_day: f64 = var(JEV_DOLLARS_PER_DAY)?
529        .parse()
530        .map_err(|_| NoJev::Broken(format!("{JEV_DOLLARS_PER_DAY} is not a number")))?;
531    let client = Client::new(FetchTransport::api(), WorkerRuntime, model.clone(), key.trim())
532        .map_err(|e| NoJev::Broken(e.to_string()))?;
533    Ok(Jev { client, model, dollars_per_day })
534}
535
536fn spent(pot: Pot) -> Ending {
537    Ending::Spent {
538        what: match pot {
539            Pot::Jev => "Jev",
540            Pot::Llm => "the LLM",
541        },
542    }
543}
544
545/// Jev's answer to a prepared call, from the archive: the response it holds
546/// for this exact request, or the one it gets now. `Err` means the call was
547/// never made, so the page must not show it.
548///
549/// Which kept response, or whether to send again, is what the page's pins
550/// say for this request (`archive::Pins::pick`); the version it got comes
551/// back beside the answer.
552async fn judged_by_jev(
553    env: &Env,
554    jev: &Jev,
555    input: &str,
556    wanted: Wanted,
557    prepared: &Prepared,
558    pins: &::archive::Pins,
559) -> Result<(Outcome, Option<view::Versions>), Ending> {
560    let id = ::archive::call_id(jev_protocol::ENDPOINT, &prepared.request);
561    let ask = Ask::Jev { input: input.to_owned(), wanted, request: prepared.request.clone(), pick: pins.pick(&id) };
562    match archive::call(env, ask).await {
563        Ok(Called::Answered(record)) => Ok((
564            ask::read(
565            &jev.model,
566            prepared,
567            Kept {
568                body: &record.response,
569                sent: record.sent(),
570                took: Duration::from_secs_f64(record.took_ms / 1000.0),
571                attempts: record.attempts,
572                request_id: record.request_id,
573            },
574            ),
575            Some(view::Versions { id, version: record.version, versions: record.versions }),
576        )),
577        Ok(Called::Failed { error, request_id, took_ms }) => {
578            Ok((Outcome::Failed { error, request_id, took: Duration::from_secs_f64(took_ms / 1000.0), dollars: 0.0 }, None))
579        }
580        Ok(Called::Spent(pot)) => Err(spent(pot)),
581        Ok(Called::NotKept) => Err(Ending::NotKept { id }),
582        Err(error) => Err(Ending::Failed { error }),
583    }
584}
585
586/// Asks Jev for `facts` about the input, all in one request, and records
587/// what they turned out to be. This is the rules' backfill: every Jev fact
588/// any live rule is waiting on, together.
589async fn learn(
590    env: &Env,
591    jev: &Jev,
592    view: &mut View,
593    facts: Vec<Fact>,
594    events: Option<&UnboundedSender<String>>,
595) -> Result<(), Ending> {
596    let failed = |error: String| Ending::Failed { error };
597    let wanted = Wanted::Facts(facts.clone());
598    let prepared = ask::wanted(&jev.model, &view.input, &wanted).map_err(|e| failed(e.to_string()))?;
599    view.facts = Some(JevCall::pending(&prepared));
600    if let Some(events) = events {
601        show(events, view);
602    }
603    let outcome = match judged_by_jev(env, jev, &view.input, wanted, &prepared, &view.pins).await {
604        Ok((outcome, kept)) => {
605            view.facts.as_mut().expect("just set").kept = kept;
606            outcome
607        }
608        Err(ending) => {
609            view.facts = None;
610            return Err(ending);
611        }
612    };
613    // Several facts can be read from one answer: each finds its question's.
614    let learned = match &outcome {
615        Outcome::Answered { judged, .. } => facts
616            .iter()
617            .map(|fact| {
618                let id = ask::question_id(*fact);
619                prepared
620                    .parts
621                    .iter()
622                    .position(|part| part.id == id)
623                    .and_then(|index| judged.get(index))
624                    .and_then(|judged| ask::learned(*fact, judged))
625                    .map(|value| (*fact, value))
626                    .ok_or_else(|| failed(format!("Jev's answer for {} is not the type that was asked", fact.name())))
627            })
628            .collect::<Result<Vec<_>, _>>(),
629        Outcome::Failed { error, .. } => Err(failed(error.clone())),
630    };
631    view.facts.as_mut().expect("just set").outcome = Some(outcome);
632    for (fact, value) in learned? {
633        view.known.learn(fact, value);
634    }
635    Ok(())
636}
637
638/// Jev's probability that it can judge the input, once the facts are in.
639fn answerable(view: &View) -> Option<f64> {
640    match view.facts.as_ref()?.judged(ask::question_id(Fact::Answerable))? {
641        Judged::Noul(p_yes) => Some(*p_yes),
642        _ => None,
643    }
644}
645
646async fn answer(
647    app: App,
648    input: String,
649    pins: ::archive::Pins,
650    visitor: String,
651    browser: Option<String>,
652    mut event: events::Event,
653    events: UnboundedSender<String>,
654) {
655    let began = js_sys::Date::now();
656    let mut view = View::new(ask::clean(&input));
657    let browsing = pins.browsing();
658    view.pins = pins;
659    // Every ask counts towards the visitor's limit, an empty one too.
660    view.ending = Some(if !meter::admit(&app.env, "ask", visitor, ASKS_PER_MINUTE).await {
661        Ending::Slow
662    } else if view.input.is_empty() {
663        Ending::Empty
664    } else {
665        decide(&app.env, &mut view, &events).await
666    });
667    show(&events, &view);
668    // What was asked, how it ended and what it took. An old version looked
669    // at is an ask all the same, and says so.
670    let (sent, kept) = view.calls();
671    event.detail = if browsing { format!("{} (browsing)", events::ending(view.ending.as_ref())) } else { events::ending(view.ending.as_ref()).to_owned() };
672    (event.sent, event.kept) = (f64::from(sent), f64::from(kept));
673    event.llm = if view.llm.is_some() { 1.0 } else { 0.0 };
674    event.took_ms = js_sys::Date::now() - began;
675    events::record(&app.env, event.about(&view.input)).await;
676    // The page keeps the versions it is looking at, and no others.
677    let pinned = PatchSignals::new(serde_json::json!({ "pins": view.pinned() }).to_string()).into_datastar_event();
678    let _ = events.unbounded_send(pinned.to_string());
679    // The budget and the feed are the home page's, and it is hidden while an
680    // answer is up (page.css), so neither is sent again here. An old version
681    // looked at is not what Jev says now, so it is not kept as the answer.
682    if matches!(view.ending, Some(Ending::Answered)) && !browsing {
683        // The rules say whether it may be shown: Jev judged it fit.
684        let listed = Network::lmjtfy().notes(&view.known, Note::List);
685        let who = browser.as_deref().map(|browser| asker(browser, &view.input));
686        archive::asked(&app.env, &view.input, view.said(), view.llm.is_some(), listed, browser.as_deref()).await;
687        // The votes on the answer as it is now kept.
688        let rating = archive::rating(&app.env, &view.input, who).await;
689        patch(&events, view::rating(rating.as_ref(), browser.is_some()));
690    }
691}
692
693/// Does what the rules say until they say the query has ended. The rules
694/// (`rules::RULES`) decide the order; this only carries each step out and
695/// records what it taught.
696async fn decide(env: &Env, view: &mut View, events: &UnboundedSender<String>) -> Ending {
697    let jev = match jev(env) {
698        Ok(jev) => jev,
699        Err(none) => return none.into(),
700    };
701    let network = Network::lmjtfy();
702    loop {
703        let (by, next) = network.decide(&view.known);
704        view.fired.extend(by);
705        let step = match next {
706            Next::Ask(facts) => learn(env, &jev, view, facts, Some(events)).await,
707            // One call writes everything the drafting rules that hold want.
708            Next::Do(Effect::Draft(_)) => drafted(env, view, &network.wants(&view.known), events).await,
709            Next::Do(Effect::Judge) => judge(env, &jev, view, events).await,
710            Next::End(End::NotAQuestion) => {
711                return match answerable(view) {
712                    Some(p_yes) => Ending::NotAQuestion { p_yes },
713                    None => Ending::Failed { error: "the rules refused an input Jev was not asked about".into() },
714                };
715            }
716            // Nothing is left to do. What to show is whatever the rules noted.
717            Next::Done if view.answered() => return Ending::Answered,
718            Next::Done => return Ending::NoQuestion,
719        };
720        if let Err(ending) = step {
721            return ending;
722        }
723        show(events, view);
724    }
725}
726
727/// Asks Jev every question the LLM wrote, in one request.
728async fn judge(env: &Env, jev: &Jev, view: &mut View, events: &UnboundedSender<String>) -> Result<(), Ending> {
729    let drafts: Vec<(String, llm::Draft)> =
730        view.tools.iter().filter_map(|tool| Some((tool.id.clone(), tool.draft()?.clone()))).collect();
731    let wanted = Wanted::Drafted(drafts);
732    let prepared =
733        ask::wanted(&jev.model, &view.input, &wanted).map_err(|e| Ending::Failed { error: e.to_string() })?;
734    view.judging = Some(JevCall::pending(&prepared));
735    show(events, view);
736    match judged_by_jev(env, jev, &view.input, wanted, &prepared, &view.pins).await {
737        Ok((outcome, kept)) => {
738            let judging = view.judging.as_mut().expect("just set");
739            judging.outcome = Some(outcome);
740            judging.kept = kept;
741        }
742        Err(ending) => {
743            view.judging = None;
744            return Err(ending);
745        }
746    }
747    view.known.learn(Fact::Judged, Value::Bool(view.any_judged()));
748    Ok(())
749}
750
751/// The LLM's questions for the input, from the archive, as the tool calls
752/// it made. A call Jev's protocol would refuse is kept, marked, and not sent.
753async fn drafted(env: &Env, view: &mut View, wants: &[Want], events: &UnboundedSender<String>) -> Result<(), Ending> {
754    let failed = |error: String| Ending::Failed { error };
755    let id = env.var(LLM_MODEL).map(|var| var.to_string()).map_err(|e| failed(format!("{LLM_MODEL}: {e}")))?;
756    let model = Model::find(&id).ok_or_else(|| failed(format!("{id} is not one of llm's candidates")))?;
757    let request = llm::request(&view.input, wants);
758    view.llm = Some(LlmCall { model: model.id, request: request.clone(), outcome: None, kept: None });
759    show(events, view);
760
761    let id = ::archive::call_id(model.id, &request);
762    let pick = view.pins.pick(&id);
763    let record = match archive::call(env, Ask::Llm { model: model.id.to_owned(), request, pick }).await {
764        Ok(Called::Answered(record)) => record,
765        Ok(Called::Failed { error, took_ms, .. }) => {
766            view.llm.as_mut().expect("just set").outcome = Some(LlmOutcome {
767                body: error.clone(),
768                neurons: 0.0,
769                took: Duration::from_secs_f64(took_ms / 1000.0),
770                dropped: 0,
771                sent: ask::Sent::Now,
772            });
773            return Err(failed(format!("the LLM: {error}")));
774        }
775        Ok(Called::Spent(pot)) => {
776            view.llm = None;
777            return Err(spent(pot));
778        }
779        Ok(Called::NotKept) => {
780            view.llm = None;
781            return Err(Ending::NotKept { id });
782        }
783        Err(error) => {
784            view.llm = None;
785            return Err(failed(error));
786        }
787    };
788    let parsed = llm::parse(&record.response);
789    view.llm.as_mut().expect("just set").kept = Some(view::Versions { id, version: record.version, versions: record.versions });
790    view.llm.as_mut().expect("just set").outcome = Some(LlmOutcome {
791        neurons: parsed.as_ref().map_or(0.0, |reply| model.neurons(reply.usage)),
792        took: Duration::from_secs_f64(record.took_ms / 1000.0),
793        dropped: parsed.as_ref().map_or(0, |reply| reply.dropped),
794        sent: record.sent(),
795        body: record.response,
796    });
797    let reply = parsed.map_err(|error| failed(format!("the LLM: {error}")))?;
798    view.tools = reply
799        .calls
800        .into_iter()
801        .enumerate()
802        .map(|(index, call)| {
803            let refused = call.draft.as_ref().ok().and_then(|draft| {
804                if !llm::takes(wants, draft) {
805                    // Jev has answered that reading itself, or the rules did not take it.
806                    return Some(format!("a {} was not what the rules asked the LLM for", draft.tool()));
807                }
808                ask::check(draft).err().map(|e| e.to_string())
809            });
810            // q1, q2, ...: the question's id in its request and on the page.
811            Tool { id: format!("q{}", index + 1), call, refused }
812        })
813        .collect();
814    let any = view.tools.iter().any(|tool| tool.draft().is_some());
815    view.known.learn(Fact::Drafted, Value::Bool(any));
816    Ok(())
817}
818
819/// The facts alone, for the line under the search box. It is the same
820/// request `/ask` begins with, so what was typed is already kept by the time
821/// it is asked.
822async fn glance(app: App, input: String, visitor: String, event: events::Event, events: UnboundedSender<String>) {
823    let mut view = View::new(ask::clean(&input));
824    let seen = if view.input.is_empty() || !meter::admit(&app.env, "glance", visitor, GLANCES_PER_MINUTE).await {
825        None
826    } else {
827        match (jev(&app.env), Network::lmjtfy().next(&view.known)) {
828            (Ok(jev), Next::Ask(facts)) => learn(&app.env, &jev, &mut view, facts, None).await.ok(),
829            _ => None,
830        }
831    };
832    let (sent, kept) = view.calls();
833    let mut event = event.with(if seen.is_some() { "seen" } else { "" });
834    (event.sent, event.kept) = (f64::from(sent), f64::from(kept));
835    events::record(&app.env, event.about(&view.input)).await;
836    let glance = seen.and_then(|()| answerable(&view)).map(|answerable| view::Glance { answerable, kind: view.reading() });
837    patch(&events, view::live(glance));
838}
839
840#[cfg(test)]
841mod tests {
842    use super::*;
843
844    #[test]
845    fn a_browser_is_the_same_asker_of_one_question_and_unrelated_across_two() {
846        let id = "0b5f2a1e-7c3d-4e8f-9a6b-1c2d3e4f5a6b";
847        assert_eq!(asker(id, "is it?"), asker(id, "is it?"));
848        assert_ne!(asker(id, "is it?"), asker(id, "is it not?"));
849        assert_ne!(asker(id, "is it?"), asker("1b5f2a1e-7c3d-4e8f-9a6b-1c2d3e4f5a6b", "is it?"));
850        assert_eq!(asker(id, "is it?").0.len(), 64);
851        assert!(!asker(id, "is it?").0.contains(id));
852    }
853
854    fn request(method: &str, host: &str, path: &str, headers: &[(&str, &str)]) -> HttpRequest {
855        let mut request = axum::http::Request::builder().method(method).uri(path).header(header::HOST, host);
856        for (name, value) in headers {
857            request = request.header(*name, *value);
858        }
859        request.body(worker::Body::empty()).unwrap()
860    }
861
862    #[test]
863    fn the_old_address_leads_to_the_new_one_for_good() {
864        let to = |method, path, headers: &[(&str, &str)]| moved(&request(method, ELSEWHERE[0], path, headers));
865        assert_eq!(moved(&request("GET", "www.lmjtfy.fun", "/rules", &[])).as_deref(), Some("https://lmjtfy.fun/rules"));
866        assert_eq!(to("GET", "/?q=is+it%3F", &[]).as_deref(), Some("https://lmjtfy.fun/?q=is+it%3F"));
867        // git's first request, which it follows; a person's navigation.
868        assert_eq!(to("GET", "/lmjtfy.git/info/refs?service=git-upload-pack", &[]).as_deref(), Some("https://lmjtfy.fun/lmjtfy.git/info/refs?service=git-upload-pack"));
869        assert!(to("GET", "/rules", &[("sec-fetch-mode", "navigate")]).is_some());
870        // A page still open there finishes there.
871        assert_eq!(to("POST", "/ask", &[]), None);
872        assert_eq!(to("GET", "/live", &[("upgrade", "websocket")]), None);
873        assert_eq!(to("GET", "/feed?ms=1&q=x", &[("sec-fetch-mode", "cors")]), None);
874        // The new address, and a dev server, are home.
875        assert_eq!(moved(&request("GET", HOME, "/", &[])), None);
876        assert_eq!(moved(&request("GET", "localhost:8787", "/", &[])), None);
877    }
878
879    #[test]
880    fn only_a_well_formed_browser_cookie_is_read() {
881        let with = |cookie: &str| {
882            let mut headers = HeaderMap::new();
883            headers.insert(header::COOKIE, cookie.parse().unwrap());
884            browser(&headers)
885        };
886        let id = "0b5f2a1e-7c3d-4e8f-9a6b-1c2d3e4f5a6b";
887        assert_eq!(with(&format!("a=b; {BROWSER}={id}")).as_deref(), Some(id));
888        assert_eq!(with(&format!("{BROWSER}=<script>")), None);
889        assert_eq!(with("a=b"), None);
890    }
891}