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, ¶ms); 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}