a tiny pet with a GPT-2 brain, living in your browser
tokenling docs architecture.md
9.9 kB
Markdown
at main

Architecture #

tokenling is a static site plus a model. Everything runs in the visitor's browser; the server only hands out files.

A deployment may enable a separate Cloudflare Web Analytics beacon for page traffic and performance. The builder adds it and its disclosure only when a site token is configured; pet state and generation remain in the browser. See deployment.md for configuration and privacy checks.

<base>/                                     (static files from any web server)
 ├─ index.html           site/index.html with its placeholders filled (site/build.sh)
 ├─ bench.html           test/bench.html with the release's model/runtime, unlinked
 ├─ sw.js                site/sw.js: offline copies and alerts
 ├─ assets/              site/assets/* (font, manifest, icons)
 ├─ app/<hash>/*.js      bundled main.js + brain.js, content-hashed together
 ├─ models/<id>/         tokenizer + config + onnx/model_q4.onnx
 └─ runtime/<name>/      transformers.min.js + ONNX Runtime Web .wasm/.mjs

browser
 ├─ main thread  main.js   Preact components + typed app controllers
 │                         scene.ts, sprites.ts and world.ts bundled inside
 ├─ worker       brain.js  loads the model, streams tokens, stops on line/lookup
 ├─ service worker sw.js   serves the offline copies, shows alerts
 ├─ localStorage "tokenling.v1"  the whole pet (JSON, see pet-simulation.md)
 ├─ Web Lock "tokenling.v1"      held by the one tab that runs the pet
 └─ Cache Storage "transformers-cache"  the downloaded model files
                  "tokenling-app"       page, app and runtime for offline use
                  "tokenling-alerts"    the alert plan

Modules #

File Role
web/main.tsx Claims the tab, loads the save, mounts Preact and resumes the cached brain.
web/components/ App.tsx owns tabs; Device.tsx/Setup.tsx own the LCD, care and setup; Chat.tsx, Diary.tsx, Shelf.tsx, Settings.tsx, Brain.tsx and Debug.tsx render the panels and dialogs. shared.tsx supplies the revision hook and sprite component.
web/types.ts, web/runtime-types.ts State, scene, worker request/response types and the small API surface used from the separately vendored transformers.js.
web/app/store.ts Mutable body/brain/view state and transient UI state. emit() increments a revision; Preact subscribes. Streaming text is data, never detached DOM nodes. Animation frames do not rerender the UI.
web/app/save-format.ts, web/app/storage.ts Save v1 defaults/validation/migrations and guarded debounced browser persistence.
web/app/body.ts, web/app/simulation.ts Pure body simulation over a supplied state, then the clock, catch-up, growth, day log and prompt state adapter. Alert projection simulates a clone.
web/app/conversation.ts, web/app/text.ts React/idle turn scheduler, lookup continuations, stale replies and tag effects; text filtering and chat chip descriptions.
web/app/care.ts, web/app/diary.ts Hatching, care offers/answers and bedtime/missed diary pages.
web/app/animation.ts, web/app/canvas.ts, web/app/game.ts Animation state, disturbances, canvas attachment/cleanup and catch physics/input.
web/app/brain.ts Model cache, download, upgrade, worker protocol and one-thread fallback.
web/app/pwa.ts, web/app/lifecycle.ts Offline resources, install prompts/alerts, Web Lock ownership and page lifecycle.
web/app/commands.ts, web/app/debug.ts UI actions for setup/chat/settings/save management, and debug controls/prompt history.
web/app/config.ts, web/app/utils.ts Host configuration, feature gates, constants and small shared helpers.
web/brain.ts Module worker: imports the vendored runtime, loads the model on WebAssembly and streams/stops generation. Bundled as brain.js.
web/world.ts Mirror of train/world.py: headers, lookup results, event lines and closed word lists.
web/scene.ts, web/sprites.ts Existing 96×64 canvas renderer and pixel art, now typed.
site/bundle.mjs, package.json, package-lock.json, tsconfig.json Pinned Preact/TypeScript/esbuild build, strict checking and Prettier formatting.
test/body.test.mjs, test/storage.test.mjs, test/fixtures/save-v1.json Body rules and compatibility with a real previous-app save.
test/browser-flow.js Repeatable playwright-cli flow for a local debug preview with a real brain.
train/migrate_v6.py Rewrites old chat headers and sleep events, rebuilds loss spans, removes old teen rows and validates the combined dataset.
test/data_rules.py Regression cases for sleep, offers, validation, loss masks and migration; includes a stand-in sleep teacher.
test/site_tools.py Stdlib-only regressions that exercise the real build, publish and preview scripts with disposable model/runtime stand-ins.
site/sw.js Service worker beside the page: keeps what the page lists for offline use (cache first; the page itself network first), shows due alerts, opens the app from one.
site/build.sh Fills site/index.html and test/bench.html from site/release.json, copies the app under its hash, the assets and the worker, and writes _headers.

site/build.sh runs the TypeScript checker and site/bundle.mjs, then hashes the emitted app/worker bytes together. The host page loads main.js and supplies only #pet-root, #pet-config, styles and static explanatory content. Preact owns all interactive markup. There is no hydration or server rendering.

The body remains independent of Preact: controllers change typed state and notify subscribers at ticks, actions and token updates. Components own form inputs, tab selection and refs. The canvas effect cancels its animation frame and removes input listeners when it unmounts (including tab takeover). The host contract is in host-page.md.

The pet's turn #

  1. Something asks for a turn (wantTurn). React turns follow an owner message, an offer, a pat, cleaning, the lights, poop, waking, growing up, coming back or the end of a game. Idle turns come every 8–20 s while the tab is visible (minutes while asleep or with "lively" off), so the pet lives on its own. pump() runs one turn at a time; react beats idle.
  2. petTurn() renders the state header (Wd.header("chat", worldState())) and asks the worker to continue Name: after the chat lines that fit (1024 tokens for react turns, 512 for idle ones).
  3. The worker streams tokens. It stops at a newline, or right after a complete lookup tag ([clock], [look], [recall:…], [yesterday]).
  4. On a lookup stop, petLine() appends {result} computed by Wd.toolResult and calls the worker again with the longer prefix. Up to three lookups per line.
  5. If new lines arrived while it was generating, the line is discarded and the turn re-run with the new context. Otherwise the line is filtered (NOT_CUTE), appended to S.chat (keeping at most two consecutive silent lines), and applyTags() performs its effects: answer an open offer ([yes] eats, bathes, takes medicine or starts the game), walk, animate, show a want bubble, save a memory, put a gift on the shelf, note something for the diary, fall asleep or show a dream. Sleeping offers wake on acceptance; other sleeping lines only show dreams. An offer the pet saw but did not answer counts as no.
  6. The chat view renders the line as plain text plus small "tool" chips, or a silent line as a short action ("Mochi toddles left, bounces").

The diary uses the same machinery with a ## tokenling diary header, the day's log as context and the prefix dear diary,; it runs to <|endoftext|> and the app strips and stores [doodle:…] stickers.

Where the model comes from #

teacher data ───────► train/gen_data.py ──► new.jsonl ─┐
older dataset ─────────────────────────────────────────┴► train/mix_data.py ► data.jsonl
                                               │ train/train.py (full fine-tune)
GPT-2 (124M) ─────────────────────────────────►ckpt (safetensors)
                                               │ train/export.sh
                                  optimum ONNX ► quantize.py ► model_q4.onnx + SHA256SUMS
                                               │ train/publish.sh
                                  <base>/models/<id>/

See training.md and export-and-runtime.md.

Constraints that shaped the design #

  • The brain decides. Every choice the pet makes comes from a tag the model emits; the app runs the body and carries out decisions. That means many short generations (an idle turn every few seconds), so speed and prompt length matter more than eloquence.
  • Context is 1024 tokens. The header is about 150 tokens, so the chat view sent to the model keeps only the newest lines that fit (fit() in brain.ts). Long-term knowledge lives in the header's remembers: and today: lines and in lookups, not in raw history.
  • GPT-2 small is small, on purpose. Behavior is taught by example; it cannot follow instructions, and its occasional silliness is part of the pet. Everything it should "know" is in the prompt format.
  • Phones. A 97 MB download, WebAssembly only, about 0.8 GB of page memory once loaded (measured on desktop Chromium; phones not measured).
  • Storage. localStorage is about 5 MB, enough for state; the model goes in Cache Storage, and the page asks for persistent storage so it is not evicted.
  • Speed. About 35 tokens/s on a 4-thread desktop WebAssembly session (see export-and-runtime.md); most turns are a few tokens.