Spaces:
Running
Running
| // Puck — app orchestration. The living loop: events spawn, | |
| // Puck flies, scores, decides, comments, learns, sleeps. | |
| import * as React from "react"; | |
| import { | |
| applyMode, | |
| buildTraces, | |
| clamp, | |
| type Decision, | |
| decide, | |
| defaultState, | |
| defaultTaste, | |
| EVENTS, | |
| type EventDef, | |
| type FairyStat, | |
| type FairyState, | |
| type FeedItem, | |
| hhmm, | |
| Learn, | |
| learn as learnTaste, | |
| type Memory, | |
| type MemorySeed, | |
| moodFor, | |
| type NightBloomResult, | |
| nightBloom, | |
| type OutcomeType, | |
| outcomeRating, | |
| type PuckMode, | |
| asPeekEmotion, | |
| type Rating, | |
| type Reaction, | |
| type ReactionKind, | |
| rateFairy, | |
| reactionForEmotion, | |
| reactionForEvent, | |
| reactionForRating, | |
| type SurfaceOutcome, | |
| score, | |
| seedMemories, | |
| shuffle, | |
| speak, | |
| summonReaction, | |
| } from "../../engine"; | |
| import { startDaemonPoll } from "../../lib/daemon"; | |
| import { fetchBloom } from "../../lib/memories"; | |
| import { fetchMoltStatus, type MoltStatus } from "../../lib/molt"; | |
| import { osNotify } from "../../lib/notify"; | |
| import { defaultSettings, inkFor, type Settings } from "../../lib/settings"; | |
| import { usePersistent } from "../../lib/storage"; | |
| import { focusApp, startHitRectReporter } from "../../lib/tauri"; | |
| import { exportTraces } from "../../lib/traces"; | |
| import type { Pos } from "../../lib/useDrag"; | |
| import { useIdle } from "../../lib/useIdle"; | |
| import { peekScene, warmVision } from "../../lib/vision"; | |
| import { | |
| cancelSpeech, | |
| kokoroSupported, | |
| loadKokoro, | |
| speakAloud, | |
| unlockAudio, | |
| type VoiceChannel, | |
| type VoiceEngine, | |
| } from "../../lib/voice"; | |
| import { type ChatMsg, Companion, type CompanionTab, type MemoryAction } from "../../ui/Companion"; | |
| import { | |
| applyEffect, | |
| DesktopWindows, | |
| Dock, | |
| type DockApp, | |
| initialWinState, | |
| MenuBar, | |
| type WinState, | |
| } from "../../ui/Desktop"; | |
| import { | |
| Interrupt, | |
| type InterruptData, | |
| MenuDropdown, | |
| NightBloom, | |
| type OfferData, | |
| type ToastItem, | |
| Toasts, | |
| } from "../../ui/Overlays"; | |
| import { SettingsPanel } from "../../ui/SettingsPanel"; | |
| import { Bubble, type BubbleData, PuckSprite, type SpriteState } from "../../ui/Sprite"; | |
| const MOOD_LABEL = { | |
| curious: "curious", | |
| mischief: "feeling mischievous", | |
| sleepy: "drowsy", | |
| proud: "proud", | |
| grumpy: "grumpy", | |
| } as const; | |
| let UID = 0; | |
| const uid = () => `u${Date.now().toString(36)}-${++UID}`; | |
| // "tap to open" in the sim: source → which sim window to focus (the overlay uses | |
| // the native focusApp instead). Best-effort; not every source has a window. | |
| const SOURCE_WINDOW: Record<string, string> = { | |
| Claude: "claude", | |
| Build: "terminal", | |
| Git: "terminal", | |
| Mail: "mail", | |
| Discord: "discord", | |
| Calendar: "calendar", | |
| }; | |
| const initialPositions = (): Record<string, Pos> => ({ | |
| claude: { x: 40, y: 60 }, | |
| terminal: { x: 40, y: 392 }, | |
| mail: { x: 486, y: 64 }, | |
| discord: { x: 486, y: 408 }, | |
| calendar: { x: 300, y: 300 }, | |
| }); | |
| type Channel = "feed" | "glow" | "bubble" | "toast" | "interrupt"; | |
| // Decision→channel stays in the orchestrator on purpose: the engine ranks importance | |
| // (the Decision ladder); how that lands on screen is a presentation policy per shell | |
| // (sim bubble today, native notification in the Tauri overlay later). | |
| function channelFor(decision: Decision, notify: Settings["notify"]): Channel { | |
| if (decision === "ignore" || decision === "scroll") return "feed"; | |
| if (decision === "glow") return "glow"; | |
| // notify or interrupt | |
| if (notify === "interrupt") return "interrupt"; | |
| if (notify === "toast") return "toast"; | |
| if (notify === "bubble") return "bubble"; | |
| // adaptive: Puck decides | |
| return decision === "interrupt" ? "interrupt" : "bubble"; | |
| } | |
| // One organism, two habitats: the sim renders a fake desktop and spawns deck | |
| // events; the overlay renders transparent over the REAL desktop and only | |
| // real daemon events fire. Same engine, same learning, same creature. | |
| export function SimApp({ overlay = false }: { overlay?: boolean } = {}) { | |
| // ---- persisted creature state ------------------------------------------- | |
| const [settings, setSettings] = usePersistent<Settings>("settings", defaultSettings); | |
| const [fs, setFs] = usePersistent<FairyState>("fairy", defaultState); | |
| const [taste, setTaste] = usePersistent("taste", defaultTaste); | |
| const [profile, setProfile] = usePersistent("profile", Learn.defaultProfile); | |
| const [memories, setMemories] = usePersistent<Memory[]>("memories", seedMemories); | |
| const [feed, setFeed] = usePersistent<FeedItem[]>("feed", () => []); | |
| // chat tab removed; setChat is still written by wake/offers (dormant, unshown) | |
| const [, setChat] = usePersistent<ChatMsg[]>("chat", () => []); | |
| // ---- ephemeral scene state ----------------------------------------------- | |
| const [positions, setPositions] = React.useState(initialPositions); | |
| const [focused, setFocused] = React.useState<string | null>(null); | |
| const [attnId, setAttnId] = React.useState<string | null>(null); | |
| const [winState, setWinState] = React.useState<WinState>(initialWinState); | |
| const [sprite, setSprite] = React.useState<SpriteState>({ | |
| x: 250, | |
| y: 470, | |
| flying: false, | |
| resting: true, | |
| facing: "right", | |
| }); | |
| const [speaking, setSpeaking] = React.useState(false); | |
| const [reaction, setReaction] = React.useState<{ kind: ReactionKind; shout?: string } | null>(null); | |
| // monotonic — bumped on every reaction so the sprite restarts the animation | |
| // even when the same gesture fires twice (back-to-back pushes still pop) | |
| const [reactKey, setReactKey] = React.useState(0); | |
| const [offer, setOffer] = React.useState<OfferData | null>(null); | |
| const [bubble, setBubble] = React.useState<BubbleData | null>(null); | |
| const [toasts, setToasts] = React.useState<ToastItem[]>([]); | |
| const [interrupt, setInterrupt] = React.useState<InterruptData | null>(null); | |
| const [sleeping, setSleeping] = React.useState<NightBloomResult | null>(null); | |
| // real molt progress for the sleep screen (+ tonight's exported count); null = daemon down | |
| const [molt, setMolt] = React.useState<(MoltStatus & { exported: number }) | null>(null); | |
| const [companion, setCompanion] = React.useState(!overlay); | |
| const [compTab, setCompTab] = React.useState<CompanionTab>("learn"); | |
| const [compPos, setCompPos] = React.useState<Pos>({ x: window.innerWidth - 404, y: 64 }); | |
| const [settingsOpen, setSettingsOpen] = React.useState(false); | |
| const [settingsPos, setSettingsPos] = React.useState<Pos>({ x: window.innerWidth - 404, y: 400 }); | |
| const [dropdown, setDropdown] = React.useState(false); | |
| const [muted, setMuted] = React.useState(false); | |
| // camo: after sitting still a while Puck blends into the desktop (glassy/transparent | |
| // skin); he snaps back the instant he acts. Pure ambient charm. | |
| const [camo, setCamo] = React.useState(false); | |
| const [mode, setMode] = React.useState<PuckMode>("patrol"); | |
| const [pulse, setPulse] = React.useState(0); | |
| const [badge, setBadge] = React.useState(0); | |
| const [clock, setClock] = React.useState(hhmm()); | |
| const mood = moodFor(fs); | |
| const setSetting = <K extends keyof Settings>(key: K, value: Settings[K]) => | |
| setSettings((s) => ({ ...s, [key]: value })); | |
| // ---- apply theme / accent / mood to <html> ------------------------------- | |
| React.useEffect(() => { | |
| document.documentElement.dataset.theme = settings.theme; | |
| }, [settings.theme]); | |
| // biome-ignore lint/correctness/useExhaustiveDependencies: overlay never changes at runtime | |
| React.useEffect(() => { | |
| document.documentElement.classList.toggle("overlay", overlay); | |
| // wake the (possibly cold, scale-to-zero) cloud vision before the first peek; in the | |
| // browser sim, greet with a one-line "waking" intro so a first-time visitor (a judge | |
| // on the hosted Space) knows the lull is Puck stretching, not a hang. | |
| warmVision(); | |
| if (!overlay) { | |
| const s = R.current.sprite; | |
| setBubble({ | |
| uid: uid(), | |
| source: "", | |
| text: "mmf… give me a moment to wake up ✨", | |
| tier: "playful", | |
| tags: ["warm"], | |
| x: s.x, | |
| y: s.y, | |
| ratable: false, | |
| }); | |
| } | |
| return startHitRectReporter(); | |
| }, []); | |
| React.useEffect(() => { | |
| const r = document.documentElement.style; | |
| r.setProperty("--accent", settings.accent); | |
| r.setProperty("--accent-ink", inkFor(settings.accent)); | |
| }, [settings.accent]); | |
| React.useEffect(() => { | |
| const c = document.documentElement.classList; | |
| for (const m of ["curious", "mischief", "sleepy", "proud", "grumpy"]) c.remove(`mood-${m}`); | |
| c.add(`mood-${sleeping ? "sleepy" : mood}`); | |
| }, [mood, sleeping]); | |
| // the mischief setting is the same dial as the fairy's mischief stat | |
| React.useEffect(() => { | |
| setFs((p) => (p.mischief === settings.mischief ? p : { ...p, mischief: settings.mischief })); | |
| }, [settings.mischief, setFs]); | |
| React.useEffect(() => { | |
| const i = setInterval(() => setClock(hhmm()), 10000); | |
| return () => clearInterval(i); | |
| }, []); | |
| // Heal a profile persisted before a source (e.g. Git) was added — otherwise the | |
| // Learning tab indexes a missing profile.sources[id] and the render throws. | |
| // biome-ignore lint/correctness/useExhaustiveDependencies: one-shot migration on mount | |
| React.useEffect(() => { | |
| setProfile((p) => Learn.ensureSources(p)); | |
| // one-time: wipe the pre-companion-mode feed (old "CLAUDE … notified" alert | |
| // entries are confusing cruft now that Puck only roams + quips) | |
| if (!localStorage.getItem("puck:companion-reset")) { | |
| localStorage.setItem("puck:companion-reset", "1"); | |
| setFeed([]); | |
| } | |
| }, []); | |
| // On the first real interaction: unlock audio (so later gesture-less speech can | |
| // play past the autoplay gate) and warm the neural model so the first utterance | |
| // isn't a cold ~80MB wait. Fires once, then detaches. | |
| React.useEffect(() => { | |
| const onFirst = () => { | |
| unlockAudio(); | |
| const s = R.current.settings; | |
| // only warm the neural model where WebGPU exists — never in the WKWebView overlay | |
| if (s.voiceEngine === "kokoro" && s.voiceSound !== "off" && kokoroSupported()) void loadKokoro(); | |
| window.removeEventListener("pointerdown", onFirst); | |
| window.removeEventListener("keydown", onFirst); | |
| }; | |
| window.addEventListener("pointerdown", onFirst); | |
| window.addEventListener("keydown", onFirst); | |
| return () => { | |
| window.removeEventListener("pointerdown", onFirst); | |
| window.removeEventListener("keydown", onFirst); | |
| }; | |
| }, []); | |
| // refs so the interval reads fresh values | |
| const R = React.useRef({ | |
| fs, | |
| taste, | |
| mode, | |
| settings, | |
| sleeping, | |
| bubble, | |
| interrupt, | |
| profile, | |
| companion, | |
| sprite, | |
| muted, | |
| }); | |
| R.current = { fs, taste, mode, settings, sleeping, bubble, interrupt, profile, companion, sprite, muted }; | |
| // ---- voice --------------------------------------------------------------- | |
| const sayAloud = (text: string, channel: VoiceChannel, reaction?: ReactionKind) => { | |
| const cur = R.current; | |
| if (cur.muted) return; // muted Puck still emotes, just silently | |
| // the overlay is a WKWebView — only the Mac-native `say` path makes sound there | |
| const engine: VoiceEngine = overlay ? "say" : cur.settings.voiceEngine; | |
| const voiceName = | |
| engine === "kokoro" | |
| ? cur.settings.kokoroVoice | |
| : engine === "say" | |
| ? cur.settings.sayVoice | |
| : cur.settings.voiceName; | |
| speakAloud(text, { | |
| mood: moodFor(cur.fs), | |
| mischief: cur.fs.mischief, | |
| mode: cur.settings.voiceSound, | |
| channel, | |
| reaction, | |
| engine, | |
| voiceName, | |
| onStart: () => setSpeaking(true), | |
| onEnd: () => setSpeaking(false), | |
| }); | |
| }; | |
| // ---- reactions (the visible personality layer) --------------------------- | |
| const reactTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined); | |
| const react = (rx: Reaction) => { | |
| uncamo(); // any gesture snaps him out of hiding | |
| setReactKey((k) => k + 1); | |
| setReaction({ kind: rx.kind, shout: rx.shout }); | |
| // speak the shout too (a bubble/interrupt sentence ~760ms later cancels and | |
| // supersedes it — so feed-only glances get the shout as their only audio) | |
| if (rx.shout && R.current.settings.speakShouts) sayAloud(rx.shout, "reaction", rx.kind); | |
| clearTimeout(reactTimer.current); | |
| reactTimer.current = setTimeout(() => setReaction(null), rx.ms); | |
| }; | |
| // short-term memory of what just happened, so a recurring event reads as | |
| // "not again" rather than fresh delight. 2-min window; id-keyed. | |
| const recent = React.useRef<{ id: string; t: number }[]>([]); | |
| const repeatsOf = (id: string): number => { | |
| const now = Date.now(); | |
| const live = recent.current.filter((e) => now - e.t < 120_000); | |
| recent.current = [...live, { id, t: now }].slice(-30); | |
| return live.filter((e) => e.id === id).length; | |
| }; | |
| // ---- sprite flight ------------------------------------------------------- | |
| const flyTimer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined); | |
| const flyTo = (x: number, y: number, after?: () => void) => { | |
| uncamo(); // he can't fly while blended in — reveal, then move | |
| setSprite((s) => ({ ...s, x, y, flying: true, resting: false, facing: x < s.x ? "left" : "right" })); | |
| clearTimeout(flyTimer.current); | |
| flyTimer.current = setTimeout(() => { | |
| setSprite((s) => ({ ...s, flying: false, resting: true })); | |
| after?.(); | |
| }, 760); | |
| }; | |
| const winCenter = (id: string): Pos => { | |
| const el = document.getElementById(`win-${id}`); | |
| if (!el) return { x: window.innerWidth / 2, y: window.innerHeight / 2 }; | |
| const r = el.getBoundingClientRect(); | |
| return { x: r.left + r.width / 2, y: r.top + 18 }; | |
| }; | |
| // ---- fire one event ------------------------------------------------------ | |
| const queue = React.useRef<EventDef[]>([]); | |
| const nextEvent = (): EventDef => { | |
| if (!queue.current.length) queue.current = shuffle(EVENTS.slice()); | |
| const ev = queue.current.pop(); | |
| if (!ev) throw new Error("empty event deck"); | |
| return ev; | |
| }; | |
| const fireEvent = (forced?: EventDef) => { | |
| const cur = R.current; | |
| if (cur.sleeping) return; | |
| const ev = forced ?? nextEvent(); | |
| // an accepted automation covers this exact event? Puck handles it silently. | |
| // (scoped per-event: a @mention still escapes the muted bog) | |
| const auto = Learn.handlesEvent(cur.profile, ev.source, ev.id); | |
| if (auto) { | |
| setWinState((w) => applyEffect(w, ev.effect)); | |
| const item: FeedItem = { | |
| uid: uid(), | |
| id: ev.id, | |
| source: ev.source, | |
| say: `Handled it for you — ${auto.done}.`, | |
| tier: "plain", | |
| tags: ["useful"], | |
| decision: "handled", | |
| time: hhmm(), | |
| rating: null, | |
| }; | |
| setFeed((f) => [item, ...f].slice(0, 60)); | |
| if (ev.target) { | |
| const t = winCenter(ev.target); | |
| flyTo(t.x, t.y, () => setTimeout(() => setAttnId(null), 1000)); | |
| } | |
| return; | |
| } | |
| const s = score(ev, cur.fs); | |
| let decision = decide(s); | |
| // LEARNED: shift the decision by what Puck knows about this source | |
| decision = Learn.applyBias(decision, Learn.sourceBias(cur.profile, ev.source)); | |
| decision = applyMode(decision, cur.mode); | |
| // mutate the relevant window | |
| setWinState((w) => applyEffect(w, ev.effect)); | |
| if (ev.target) setAttnId(ev.target); | |
| const v = speak(ev, cur.fs, cur.taste); | |
| // emote about it even when there are no words — Puck reacting to everything | |
| // on screen is the "marginally useful, always alive" character | |
| react(reactionForEvent(ev, { repeats: repeatsOf(ev.id), mischief: cur.fs.mischief })); | |
| // muted = stay watching but go quiet: everything drops to the feed, no popups | |
| let channel = cur.muted ? "feed" : channelFor(decision, cur.settings.notify); | |
| // the overlay has no sim windows to anchor a speech bubble to — surface every | |
| // notification as a corner toast (tap to open / fling to swat), never a bubble | |
| if (overlay && channel === "bubble") channel = "toast"; | |
| const item: FeedItem = { | |
| uid: uid(), | |
| id: ev.id, | |
| source: ev.source, | |
| say: v.text, | |
| tier: v.tier, | |
| tags: v.tags, | |
| decision, | |
| time: hhmm(), | |
| rating: null, | |
| channel, | |
| }; | |
| setFeed((f) => [item, ...f].slice(0, 60)); | |
| // surfaced channels get an engagement clock; how (and whether) the user | |
| // resolves them becomes a training label at Night Bloom | |
| if (channel === "bubble" || channel === "toast" || channel === "interrupt") markSurfaced(item.uid); | |
| const target = ev.target | |
| ? winCenter(ev.target) | |
| : { x: 200 + Math.random() * 400, y: 200 + Math.random() * 200 }; | |
| if (channel === "feed") { | |
| // a glance, no words | |
| if (ev.target) flyTo(target.x, target.y, () => setTimeout(() => setAttnId(null), 1400)); | |
| else setAttnId(null); | |
| return; | |
| } | |
| if (channel === "glow") { | |
| flyTo(target.x, target.y); | |
| if (!cur.companion) setBadge((b) => b + 1); | |
| setTimeout(() => setAttnId(null), 2600); | |
| return; | |
| } | |
| if (channel === "interrupt") { | |
| // Overlay: no screen takeover. Puck demands attention with a big "summon" | |
| // dance + a (non-blocking) toast, not a modal — user feedback: a dance beats | |
| // taking over the screen. The sim keeps the modal (its "take me there" can | |
| // focus a sim window; the overlay can't focus real apps yet). | |
| if (overlay) { | |
| // dance in the open space just LEFT of the toast stack (top:34 right:12 | |
| // w:330), not under it — he was flying into the notifications and hiding | |
| flyTo(window.innerWidth - 420, 96); | |
| react(summonReaction()); | |
| setToasts((ts) => [...ts, { uid: item.uid, source: ev.source, say: v.text }]); | |
| if (!cur.companion) setBadge((b) => b + 1); | |
| mirrorToOs(item.uid, v.text); | |
| sayAloud(v.text, "interrupt"); // interrupt channel speaks even in whisper | |
| setTimeout(() => setAttnId(null), 2600); | |
| return; | |
| } | |
| flyTo(window.innerWidth / 2, window.innerHeight / 2 - 80); | |
| setInterrupt({ uid: item.uid, say: v.text, target: ev.target }); | |
| mirrorToOs(item.uid, v.text); | |
| sayAloud(v.text, "interrupt"); | |
| return; | |
| } | |
| if (channel === "toast") { | |
| flyTo(window.innerWidth - 420, 96); // beside the toast stack, not under it | |
| setToasts((ts) => [...ts, { uid: item.uid, source: ev.source, say: v.text }]); | |
| if (!cur.companion) setBadge((b) => b + 1); | |
| mirrorToOs(item.uid, v.text); | |
| sayAloud(v.text, "toast"); | |
| setTimeout(() => setAttnId(null), 2600); | |
| return; | |
| } | |
| // bubble | |
| flyTo(target.x, target.y, () => { | |
| setBubble({ | |
| uid: item.uid, | |
| source: ev.source, | |
| text: v.text, | |
| tier: v.tier, | |
| tags: v.tags, | |
| x: target.x, | |
| y: target.y, | |
| ratable: true, | |
| }); | |
| mirrorToOs(item.uid, v.text); | |
| sayAloud(v.text, "bubble"); | |
| }); | |
| if (!cur.companion) setBadge((b) => b + 1); | |
| }; | |
| // ---- engagement outcomes (fine-tune labels) ------------------------------- | |
| const surfaced = React.useRef(new Map<string, { at: number; visible: boolean }>()); | |
| const markSurfaced = (feedUid: string) => { | |
| surfaced.current.set(feedUid, { at: Date.now(), visible: document.visibilityState === "visible" }); | |
| }; | |
| // tab buried → reach through the OS; a click is real engagement | |
| const mirrorToOs = (feedUid: string, text: string) => { | |
| const cur = R.current; | |
| if (!cur.settings.osNotify || !document.hidden) return; | |
| osNotify(text, () => { | |
| resolveOutcome(feedUid, "clicked_through"); | |
| setCompanion(true); | |
| setBadge(0); | |
| }); | |
| }; | |
| const resolveOutcome = (feedUid: string, type: OutcomeType, rating: Rating | null = null) => { | |
| const s = surfaced.current.get(feedUid); | |
| if (!s) return; // not a tracked surface (or already resolved) | |
| surfaced.current.delete(feedUid); | |
| const outcome: SurfaceOutcome = { type, rating, latencyMs: Date.now() - s.at, visible: s.visible }; | |
| setFeed((f) => f.map((x) => (x.uid === feedUid ? { ...x, outcome } : x))); | |
| }; | |
| // ---- ratings & learning -------------------------------------------------- | |
| const applyRating = (feedUid: string, rating: Rating, tags?: FeedItem["tags"]) => { | |
| const item = feed.find((x) => x.uid === feedUid); | |
| const source = item?.source; | |
| setTaste((tt) => learnTaste(tt, tags?.length ? tags : ["useful", "warm"], rating)); | |
| setFeed((f) => f.map((x) => (x.uid === feedUid ? { ...x, rating } : x))); | |
| setFs((p) => rateFairy(p, rating)); | |
| // LEARNED: push this source's interruption bias, then maybe offer to act | |
| if (source) { | |
| setProfile((pr) => { | |
| const next = Learn.rateSource(pr, source, rating); | |
| const o = Learn.automationOffer(next, source); | |
| if (o) | |
| setTimeout(() => setOffer({ uid: uid(), source: o.source, verb: o.verb, offer: o.offer }), 900); | |
| return next; | |
| }); | |
| } | |
| react(reactionForRating(rating)); | |
| setPulse((p) => p + 1); | |
| setAttnId(null); | |
| }; | |
| // accept / decline a learned automation | |
| const acceptOffer = () => { | |
| if (!offer) return; | |
| setProfile((pr) => Learn.acceptAutomation(pr, offer.source)); | |
| setChat((c) => [ | |
| ...c, | |
| { | |
| who: "puck", | |
| text: `Done. I'll handle ${offer.verb} from now on. You won't hear about it unless it matters.`, | |
| }, | |
| ]); | |
| setFeed((f) => [ | |
| { | |
| uid: uid(), | |
| id: `learned_${offer.source}`, | |
| source: offer.source, | |
| say: `Learned a new trick — I'll ${offer.verb} from now on.`, | |
| tier: "plain" as const, | |
| tags: ["useful" as const], | |
| decision: "learned" as const, | |
| time: hhmm(), | |
| rating: null, | |
| }, | |
| ...f, | |
| ]); | |
| setOffer(null); | |
| }; | |
| const declineOffer = () => { | |
| if (offer) setProfile((pr) => Learn.declineAutomation(pr, offer.source)); | |
| setOffer(null); | |
| }; | |
| const rateBubble = (rating: Rating) => { | |
| if (bubble) { | |
| resolveOutcome(bubble.uid, "rated", rating); | |
| applyRating(bubble.uid, rating, bubble.tags); | |
| } | |
| setBubble(null); | |
| }; | |
| const dismissBubble = () => { | |
| if (bubble) resolveOutcome(bubble.uid, "dismissed"); | |
| setBubble(null); | |
| setAttnId(null); | |
| }; | |
| // Toast interactions ARE the learning signal (no rating buttons): a tap opens | |
| // the app it's about (positive), a fling swats it away (negative). Both just | |
| // record an outcome — Night Bloom turns the day's behavior into taste/bias. | |
| const goToast = (tUid: string) => { | |
| const t = toasts.find((x) => x.uid === tUid); | |
| resolveOutcome(tUid, "clicked_through"); | |
| if (t) { | |
| if (overlay) void focusApp(t.source); | |
| else if (SOURCE_WINDOW[t.source]) setFocused(SOURCE_WINDOW[t.source]); | |
| } | |
| setToasts((ts) => ts.filter((x) => x.uid !== tUid)); | |
| setAttnId(null); | |
| }; | |
| const swatToast = (tUid: string) => { | |
| resolveOutcome(tUid, "swatted"); | |
| setToasts((ts) => ts.filter((x) => x.uid !== tUid)); | |
| setAttnId(null); | |
| }; | |
| const rateInterrupt = (rating: Rating) => { | |
| if (interrupt) { | |
| // "Take me there" is stronger than a thumbs-up — the interrupt earned a context switch | |
| resolveOutcome(interrupt.uid, rating === "helpful" ? "clicked_through" : "rated", rating); | |
| applyRating(interrupt.uid, rating, feed.find((f) => f.uid === interrupt.uid)?.tags); | |
| if (rating === "helpful" && interrupt.target) setFocused(interrupt.target); | |
| } | |
| setInterrupt(null); | |
| }; | |
| // ---- the companion loop: roam → peek at the patch under him → quip -------- | |
| // This IS Puck now: an ambient creature that drifts around, peeks at the small | |
| // region he's hovering over, and murmurs one whimsical line about what he sees. | |
| // No alerts, no events — calm-tech "Diversion" by default. (The old event/alert | |
| // path is dormant; see the daemon poll below.) | |
| const wnCD = React.useRef(5); | |
| const pkCD = React.useRef(7); // ticks (≈s) until the first peek | |
| const CAMO_TICKS = 7; // seconds of stillness before he blends in | |
| const camoCD = React.useRef(CAMO_TICKS); | |
| const uncamo = () => { | |
| setCamo(false); | |
| camoCD.current = CAMO_TICKS; // reset the stillness clock so he doesn't re-blend at once | |
| }; | |
| const idle = useIdle(); | |
| const idleRef = React.useRef(idle); | |
| idleRef.current = idle; | |
| const peeking = React.useRef(false); | |
| // the patch he's peering at: a window-ish box offset in his facing direction | |
| // (so he's never in his own shot), clamped on-screen. Logical/CSS px. | |
| const peekRegion = (s: SpriteState) => { | |
| // a window-sized patch (logical px); peekNative scales by devicePixelRatio, | |
| // so this covers the same screen area on retina. Bigger = more context for a | |
| // better comment, still one fast inference (no tiling). | |
| const W = 820; | |
| const H = 560; | |
| const x0 = s.facing === "left" ? s.x - 16 - W : s.x + 16; | |
| return { | |
| x: Math.round(Math.max(0, Math.min(window.innerWidth - W, x0))), | |
| y: Math.round(Math.max(0, Math.min(window.innerHeight - H, s.y - H / 2))), | |
| w: W, | |
| h: H, | |
| }; | |
| }; | |
| const doPeek = async () => { | |
| if (peeking.current) return; | |
| peeking.current = true; | |
| uncamo(); // surface to take a look | |
| const html = document.documentElement; | |
| try { | |
| const region = peekRegion(R.current.sprite); | |
| // BLANK Puck's own UI (sprite, bubbles, the open companion panel) so the | |
| // screenshot is the real desktop, not himself reading his own feed. The | |
| // Rust capture grabs the region first thing; unblank shortly after (well | |
| // before the ~5s VLM finishes) so it's only a brief flicker, not a freeze. | |
| html.classList.add("capturing"); | |
| await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); | |
| const resultPromise = peekScene(R.current.fs, region); | |
| setTimeout(() => html.classList.remove("capturing"), 800); // capture is done by now | |
| const result = await resultPromise; | |
| console.log("puck: peek", JSON.stringify(region), "→", result?.emotion, result?.quip); | |
| if (!result) return; | |
| const { quip, emotion } = result; | |
| const s = R.current.sprite; | |
| // FEEL it first — the VLM-classified emotion drives a one-shot gesture + shout | |
| // (laugh, NANI-confusion, fret, …); the bubble sentence ~760ms later supersedes | |
| // the shout's audio, so we hear the shout then the quip. | |
| const rx = reactionForEmotion(asPeekEmotion(emotion), { mischief: R.current.fs.mischief }); | |
| react(rx); | |
| // the quip IS the bubble — a self-dismissing murmur, no actions, no rating | |
| setBubble({ | |
| uid: uid(), | |
| source: "", | |
| text: quip, | |
| tier: "playful", | |
| tags: ["warm"], | |
| x: s.x, | |
| y: s.y, | |
| ratable: false, | |
| }); | |
| // the persisted Memory tab (daemon peek log) is the record now — no feed. | |
| // Speak the quip in the SAME emotional register as the gesture (rate + pitch tilt). | |
| sayAloud(quip, "bubble", rx.kind); | |
| } finally { | |
| html.classList.remove("capturing"); | |
| peeking.current = false; | |
| } | |
| }; | |
| // biome-ignore lint/correctness/useExhaustiveDependencies: interval reads via refs | |
| React.useEffect(() => { | |
| const iv = setInterval(() => { | |
| const cur = R.current; | |
| if (cur.sleeping) return; | |
| const presence = cur.settings.presence; | |
| const restful = !cur.bubble && !cur.sprite.flying; | |
| // wander | |
| wnCD.current -= 1; | |
| if (wnCD.current <= 0) { | |
| wnCD.current = Math.round(3 + (100 - presence) / 12); | |
| if (presence > 14 && restful && Math.random() < presence / 90) { | |
| const x = 120 + Math.random() * (window.innerWidth - 460); | |
| const y = 110 + Math.random() * (window.innerHeight - 320); | |
| flyTo(x, y); | |
| } | |
| } | |
| // peek + quip on an ambient cadence, paused when away / busy | |
| pkCD.current -= 1; | |
| if (pkCD.current <= 0) { | |
| pkCD.current = 50 + Math.floor(Math.random() * 45); // ~50-95s, jittered | |
| if (restful && !idleRef.current && !document.hidden && !peeking.current) void doPeek(); | |
| } | |
| // camo: blend into the desktop after sitting still a while (uncloak is snappy and | |
| // handled at the action sites — react/flyTo/doPeek/poke — so here we only fade IN) | |
| if (restful && !peeking.current && !document.hidden) { | |
| camoCD.current -= 1; | |
| if (camoCD.current <= 0) setCamo(true); | |
| } else { | |
| camoCD.current = CAMO_TICKS; | |
| setCamo(false); | |
| } | |
| }, 1000); | |
| return () => clearInterval(iv); | |
| }, []); | |
| // auto-dismiss an unrated bubble after a while (becomes part of the scroll); | |
| // expiry is itself a label: surfaced, seen or not, never engaged | |
| // biome-ignore lint/correctness/useExhaustiveDependencies: resolveOutcome is recreated per render but stable in behavior | |
| React.useEffect(() => { | |
| if (!bubble) return; | |
| const tm = setTimeout(() => { | |
| resolveOutcome(bubble.uid, "expired"); | |
| setBubble(null); | |
| }, 11000); | |
| return () => clearTimeout(tm); | |
| }, [bubble]); | |
| // toasts you neither open nor swat expire on their own — that silence is a | |
| // (mild) signal too, and it stops the corner from stacking up notifications | |
| // biome-ignore lint/correctness/useExhaustiveDependencies: resolveOutcome stable in behavior | |
| React.useEffect(() => { | |
| if (!toasts.length) return; | |
| const iv = setInterval(() => { | |
| const now = Date.now(); | |
| const dead = toasts.filter((t) => { | |
| const s = surfaced.current.get(t.uid); | |
| return s && now - s.at > 15000; | |
| }); | |
| if (!dead.length) return; | |
| for (const t of dead) resolveOutcome(t.uid, "expired"); | |
| setToasts((ts) => ts.filter((t) => !dead.some((d) => d.uid === t.uid))); | |
| }, 1000); | |
| return () => clearInterval(iv); | |
| }, [toasts]); | |
| // ---- real events from the local daemon (DORMANT in companion mode) ------- | |
| // For the hackathon Puck is purely a companion that roams + comments — he does | |
| // NOT alert on hooks (Claude finishing, builds, mentions). We still drain the | |
| // queue so it doesn't grow, but surface nothing. The narrate→fireEvent path | |
| // below stays in the code for when "useful alerts" come back later. | |
| // biome-ignore lint/correctness/useExhaustiveDependencies: poll reads via refs; bind once | |
| React.useEffect(() => { | |
| return startDaemonPoll(() => { | |
| /* companion mode: drain + ignore — no alerting on hook events */ | |
| }); | |
| }, []); | |
| // ---- memory garden actions ---------------------------------------------- | |
| const memAction = (kind: MemoryAction, id: string) => { | |
| setMemories((ms) => { | |
| if (kind === "prune") return ms.filter((m) => m.id !== id); | |
| return ms.map((m) => | |
| m.id !== id | |
| ? m | |
| : kind === "water" | |
| ? { ...m, salience: Math.min(1, m.salience + 0.12) } | |
| : { ...m, pinned: !m.pinned, salience: m.pinned ? m.salience : 1 }, | |
| ); | |
| }); | |
| }; | |
| // ---- sleep / wake -------------------------------------------------------- | |
| const beginSleep = () => { | |
| cancelSpeech(); | |
| setSpeaking(false); | |
| setBubble(null); | |
| setInterrupt(null); | |
| setToasts([]); | |
| setDropdown(false); | |
| setOffer(null); | |
| setSettingsOpen(false); | |
| // export the day as training traces before the feed is composted. | |
| // Anything still on screen resolves as "unresolved" with its dwell time. | |
| const exportFeed = feed.map((x) => { | |
| const s = surfaced.current.get(x.uid); | |
| return s && !x.outcome | |
| ? { | |
| ...x, | |
| outcome: { | |
| type: "unresolved" as const, | |
| rating: null, | |
| latencyMs: Date.now() - s.at, | |
| visible: s.visible, | |
| }, | |
| } | |
| : x; | |
| }); | |
| surfaced.current.clear(); | |
| const records = buildTraces(exportFeed, fs, taste, profile); | |
| // export first, THEN read status so the gauge includes tonight's just-appended traces | |
| void exportTraces(records).then(() => | |
| fetchMoltStatus().then((m) => setMolt(m && { ...m, exported: records.length })), | |
| ); | |
| setSleeping(nightBloom(feed)); | |
| // the day's peeks BLOOM into durable garden memories (LLM distill, ~slow) — | |
| // they stream into the sleep result, then wake() plants them in the Garden | |
| void fetchBloom({ mischief: fs.mischief, mood: moodFor(fs) }).then((bloomed) => { | |
| if (bloomed.length) setSleeping((r) => (r ? { ...r, memories: [...bloomed, ...r.memories] } : r)); | |
| }); | |
| }; | |
| const wake = () => { | |
| const r = sleeping; | |
| if (r) { | |
| setFs((p) => { | |
| const n: FairyState = { ...p, ageDays: p.ageDays + 1, energy: 88 }; | |
| for (const [k, v] of Object.entries(r.deltas) as [FairyStat | "age", number][]) { | |
| if (k !== "age") n[k] = clamp(n[k] + v); | |
| } | |
| return n; | |
| }); | |
| if (r.deltas.mischief) setSetting("mischief", clamp(fs.mischief + r.deltas.mischief)); | |
| if (r.memories.length) { | |
| setMemories((ms) => [ | |
| ...r.memories.map((m: MemorySeed, i: number) => ({ | |
| ...m, | |
| id: `n${Date.now()}${i}`, | |
| salience: 0.7, | |
| pinned: false, | |
| })), | |
| ...ms, | |
| ]); | |
| } | |
| setChat((c) => [...c, { who: "puck", text: r.morning }]); | |
| // consolidate the day's BEHAVIOR into per-source interruption bias overnight | |
| // (implicit only — items rated explicitly in the sim already shifted bias live) | |
| setProfile((pr) => { | |
| let next = pr; | |
| for (const item of feed) { | |
| if (item.rating) continue; | |
| const rt = outcomeRating(item); | |
| if (rt) next = Learn.rateSource(next, item.source, rt); | |
| } | |
| return next; | |
| }); | |
| } | |
| setSleeping(null); | |
| setPulse((p) => p + 1); | |
| setFeed([]); | |
| setWinState(initialWinState()); | |
| }; | |
| // ---- status line for dropdown ------------------------------------------- | |
| const lastNotable = feed.find((f) => f.decision !== "ignore"); | |
| const status = lastNotable ? lastNotable.say : "All quiet. I'm patrolling the desktops you left behind."; | |
| const openCompanion = () => { | |
| uncamo(); // poked — pop into view | |
| setCompanion(true); | |
| setBadge(0); | |
| setDropdown(false); | |
| }; | |
| // Mute = Puck keeps watching (events still log to the feed) but goes silent and | |
| // stops popping up. Muting now also cuts any audio/surface mid-flight. | |
| const toggleMute = () => { | |
| setDropdown(false); | |
| setMuted((m) => { | |
| if (!m) { | |
| cancelSpeech(); | |
| setBubble(null); | |
| setInterrupt(null); | |
| setToasts([]); | |
| } | |
| return !m; | |
| }); | |
| }; | |
| // menu "Look around": a manual peek-and-quip (same as the ambient loop, on demand) | |
| const lookAround = () => { | |
| setDropdown(false); | |
| void doPeek(); | |
| }; | |
| const dockApps: DockApp[] = React.useMemo( | |
| () => [ | |
| { id: "finder", name: "Finder", glyph: "🗂" }, | |
| { | |
| id: "claude", | |
| name: "Claude Code", | |
| glyph: "✳", | |
| running: true, | |
| badge: winState.claude === "done" ? 1 : 0, | |
| }, | |
| { id: "terminal", name: "Terminal", glyph: "▦", running: true }, | |
| { id: "mail", name: "Mail", glyph: "✉", badge: winState.mail.length }, | |
| { id: "discord", name: "Discord", glyph: "◍", badge: winState.discordMention ? 1 : 0 }, | |
| { id: "calendar", name: "Calendar", glyph: "◷" }, | |
| { id: "puck", name: "Puck", glyph: "❖", running: true, tint: "var(--accent)" }, | |
| ], | |
| [winState], | |
| ); | |
| const onDock = (id: string) => { | |
| if (id === "puck") openCompanion(); | |
| else if (positions[id]) setFocused(id); | |
| }; | |
| return ( | |
| <> | |
| {!overlay && ( | |
| <> | |
| <div className="wall-spores" /> | |
| <div className="vignette" /> | |
| <MenuBar | |
| moodLabel={MOOD_LABEL[mood]} | |
| puckActive={dropdown} | |
| badge={badge} | |
| onPuck={() => setDropdown((d) => !d)} | |
| clock={clock} | |
| /> | |
| <DesktopWindows | |
| positions={positions} | |
| onPos={(id, p) => setPositions((ps) => ({ ...ps, [id]: p }))} | |
| onFocus={setFocused} | |
| focused={focused} | |
| attnId={attnId} | |
| winState={winState} | |
| /> | |
| <Dock apps={dockApps} onOpen={onDock} /> | |
| </> | |
| )} | |
| <Companion | |
| open={companion} | |
| pos={compPos} | |
| onPos={setCompPos} | |
| onClose={() => setCompanion(false)} | |
| tab={compTab} | |
| setTab={setCompTab} | |
| memories={memories} | |
| onMem={memAction} | |
| fs={fs} | |
| moodLabel={MOOD_LABEL[mood]} | |
| pulseKey={pulse} | |
| /> | |
| <PuckSprite | |
| sprite={sprite} | |
| form={settings.form} | |
| speaking={speaking} | |
| alert={!!bubble || !!interrupt || toasts.length > 0} | |
| reaction={reaction?.kind ?? null} | |
| shout={reaction?.shout} | |
| reactKey={reactKey} | |
| muted={muted} | |
| camo={camo} | |
| onPoke={openCompanion} | |
| onMenu={() => setDropdown((d) => !d)} | |
| /> | |
| {bubble && ( | |
| <Bubble data={bubble} bubbleStyle={settings.voice} onRate={rateBubble} onDismiss={dismissBubble} /> | |
| )} | |
| <Toasts | |
| items={toasts} | |
| bubbleStyle={settings.voice} | |
| onGo={goToast} | |
| onSwat={swatToast} | |
| offer={offer} | |
| onAccept={acceptOffer} | |
| onDecline={declineOffer} | |
| /> | |
| <Interrupt data={interrupt} form={settings.form} onRate={rateInterrupt} /> | |
| <MenuDropdown | |
| open={dropdown} | |
| status={status} | |
| mode={mode} | |
| setMode={setMode} | |
| muted={muted} | |
| onMute={toggleMute} | |
| onCompanion={openCompanion} | |
| onPatrol={() => { | |
| setDropdown(false); | |
| fireEvent(); | |
| }} | |
| onLook={lookAround} | |
| onSleep={() => { | |
| setDropdown(false); | |
| beginSleep(); | |
| }} | |
| onSettings={() => { | |
| setDropdown(false); | |
| setSettingsOpen(true); | |
| }} | |
| onClose={() => setDropdown(false)} | |
| /> | |
| {sleeping && <NightBloom result={sleeping} molt={molt} onWake={wake} />} | |
| <SettingsPanel | |
| open={settingsOpen} | |
| pos={settingsPos} | |
| onPos={setSettingsPos} | |
| onClose={() => setSettingsOpen(false)} | |
| settings={settings} | |
| onChange={setSetting} | |
| overlay={overlay} | |
| onPatrol={() => fireEvent()} | |
| onSleep={beginSleep} | |
| /> | |
| </> | |
| ); | |
| } | |