Something went wrong. Try again.
a tiny pet with a GPT-2 brain, living in your browser
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220import type { TurnKind } from "../types.ts";import * as Wd from "../world.ts";import { animate, dream, float, walk, want } from "./animation.ts";import { gen } from "./brain.ts";import { answerOffer } from "./care.ts";import { MIN } from "./config.ts";import { onSleep } from "./diary.ts";import { logToday, worldState } from "./simulation.ts";import { save } from "./storage.ts";import { brain, emit, liveBubble, store, toast, view } from "./store.ts";import { cleanText, NOT_CUTE } from "./text.ts";import { rand } from "./utils.ts";
let lineCount = 0; // bumps on every new line; a turn whose context changed is staleexport const isSilent = (body: string) => !/[a-z]/i.test(cleanText(body));export function pushLine(line: string) { // Keep at most two silent pet lines (only tags: walking, emotes) in a row so // idling does not push the conversation out of the model's context. const p = store.state.pet?.name; const silent = (l: string) => p && l.startsWith(p + ": ") && isSilent(l.slice(p.length + 2)); if ( silent(line) && silent(store.state.chat.at(-1) || "") && silent(store.state.chat.at(-2) || "") ) store.state.chat.splice(-2, 1); store.state.chat.push(line); if (store.state.chat.length > 240) store.state.chat.splice(0, store.state.chat.length - 240); lineCount++; save(); emit();}
const LOOKUP_TAIL = /\[(clock|look|yesterday|recall)(?::([^\]\n]{1,80}))?\]$/;
// One pet line, with up to three lookup round-trips (the "agentic" bit).// Idle turns see a shorter history: prefill is most of their cost.async function petLine(kind: TurnKind, live: (text: string) => void) { const st = worldState(); const header = Wd.header("chat", st); let text = ""; for (let hop = 0; hop < 4; hop++) { // End the prompt at "Name:" (no trailing space): GPT-2 merges the space // into the next token, so a bare trailing space derails it. const r = await gen( { header, lines: store.state.chat, prefix: `${store.state.pet!.name}:${text}`, mode: "line", maxNew: kind === "idle" ? 40 : 64, temperature: 0.85, ctx: kind === "idle" ? 512 : 1024, }, (partial) => live(text + partial), ); brain.tps = r.tps || brain.tps; brain.prefillMs = r.prefillMs; text += r.text; const m = r.reason === "lookup" && text.match(LOOKUP_TAIL); if (!m || hop === 3) break; text += `{${Wd.toolResult(st, m[1], m[2])}}`; live(text); } return text.trim().replace(/\s+/g, " ");}
// Turn scheduling. "react" turns answer something that happened (a message,// an offer, a pat); "idle" turns let the pet live on its own: walk around,// mutter, want things, nap. A react request replaces a waiting idle one.export let turnWanted: TurnKind | null = null;let nextIdle = Date.now() + 6000;export function wantTurn(kind: TurnKind = "react") { if (kind === "react" || !turnWanted) turnWanted = kind; setTimeout(pump, 0);}export function pump() { if ( !store.tabOwned || !turnWanted || brain.busy || brain.state !== "ready" || !store.state.pet || view.game ) return; const kind = turnWanted; turnWanted = null; petTurn(kind).finally(() => { if (turnWanted) setTimeout(pump, 120); });}function idleGap() { if (store.state.asleep) return rand(3, 6) * MIN; return store.state.settings.chatter ? rand(8e3, 20e3) : rand(2, 4) * MIN;}export function maybeIdle() { if ( brain.state !== "ready" || brain.busy || turnWanted || view.game || document.hidden || !store.state.pet ) return; if (Date.now() < nextIdle) return; nextIdle = Date.now() + idleGap(); wantTurn("idle");}
let staleRuns = 0;export async function petTurn(kind: TurnKind = "react") { if (!store.tabOwned || !store.state.pet || brain.state !== "ready" || brain.busy) return false; brain.busy = true; emit(); view.thinking = kind === "react"; const seen = lineCount, offer = store.state.offer, wasAsleep = store.state.asleep; let bubble = kind === "react" ? liveBubble() : null; const live = (t: string) => { const shown = NOT_CUTE.test(t) ? "…" : cleanText(t); if (!bubble && !shown) return; // silent so far: nothing to show (bubble ||= liveBubble()).update(shown); }; try { let text = ""; for (let tries = 0; tries < 3; tries++) { text = await petLine(kind, live); if (!NOT_CUTE.test(text)) break; text = ""; } bubble?.remove(); // Something happened while it was thinking (a message, an offer, a pat): // throw this line away and think again with the new context. if (store.state.asleep !== wasAsleep || (lineCount !== seen && staleRuns < 2)) { staleRuns++; turnWanted = "react"; return true; } staleRuns = 0; if (!text || /^[\W_]*$/.test(text)) { if (offer && store.state.offer === offer) answerOffer(false); return true; } pushLine(`${store.state.pet!.name}: ${text}`); applyTags(text); // An offer the pet saw but did not answer counts as a no. if (offer && store.state.offer === offer) answerOffer(false); emit(); } catch (e) { bubble?.remove(); console.warn(e); } finally { brain.busy = false; view.thinking = false; nextIdle = Math.max(nextIdle, Date.now() + idleGap()); emit(); } return true;}
// Effects of the pet's own tool calls.export function applyTags(text: string) { const sleeping = store.state.asleep; const tags = [...text.matchAll(Wd.TAG_RE)]; const answers = tags.filter((m) => Wd.ANSWER_TOOLS.has(m[1])); // Resolve the answer once, independent of tag order; ambiguous answers do nothing. if (store.state.offer && answers.length === 1 && !answers[0][2] && !answers[0][3]) answerOffer(answers[0][1] === "yes"); for (const m of tags) { const [, name, arg, result] = m; if (result && Wd.EFFECT_TOOLS.has(name)) continue; if (name === "dream") { if (sleeping && store.state.asleep && Wd.DREAMS.includes(arg)) dream(arg); continue; } // A sleeping answer can only dream or accept/refuse the offer, even if // accepting it has just woken the pet. All other effects wait for next turn. if (sleeping || store.state.asleep) continue; if (name === "emote" && Wd.EMOTES.includes(arg)) { animate(arg, arg === "spin" ? 1300 : 2000); if (arg === "love" || arg === "blush") float("smallheart", 3); if (arg === "dance") float("note", 3); if (arg === "wow") float("bang", 1); } else if (name === "want" && Wd.WANTS.includes(arg)) { want(arg); } else if (name === "walk" && Wd.WALKS.includes(arg)) { walk(arg); } else if (name === "remember" && arg) { const fact = arg.trim(); if (!store.state.memories.some((x) => x.toLowerCase() === fact.toLowerCase())) { store.state.memories.push(fact); if (store.state.memories.length > 60) store.state.memories.shift(); logToday(`learned: ${fact}`); toast(`${store.state.pet!.name} will remember: ${fact}`); } } else if (name === "gift" && Wd.GIFTS.includes(arg)) { store.state.shelf.push(arg); if (store.state.shelf.length > 24) store.state.shelf.shift(); logToday(`gave ${store.state.owner} a ${arg}`); toast(`${store.state.pet!.name} gave you a ${arg}!`, arg); float(arg, 1); } else if (name === "diary" && arg) { logToday(`note: ${arg.trim()}`); } else if (name === "sleep" && !arg && !store.state.asleep) { store.state.asleep = true; logToday("napped"); onSleep(); } } save(); emit();}
export function scheduleIdle(delay = idleGap()) { nextIdle = Date.now() + delay;}