import 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 stale export 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; }