Files
zeekay 21d5a549cb The whale keeps the left, and Blue answers for the foundation too
The clip is the page rather than a picture on it, and the conversation was
sitting in the middle of it — a column of type over the animal it exists to
show. On a wide screen the column is now on the right and Blue swims in the two
thirds nobody is typing over. It moves once, on the first question: before
anyone speaks it sits wider and further in, because the greeting is the only
content on the page and a narrow ribbon at the edge is not an opening; on the
first question it settles to its talking width at the edge. Two concrete
lengths, so it animates rather than jumping. A phone has no two thirds to
spare, so below 60rem nothing changes.

Blue also answers for the foundation now, not only for the ocean — on the front
door that is most of what people ask. The counts come from the surface Blue is
standing on: zoo.ngo counts its own papers, proposals and open models from the
sources, and hands them over. Blue states those numbers and guesses none. They
arrive as three integers in a plausible range or they do not arrive at all,
because anything a URL can carry reaches a prompt.
2026-09-07 17:56:59 -07:00

137 lines
5.8 KiB
TypeScript

// Blue, the beluga who reads as a scientist and shows how it feels.
//
// The feeling vocabulary is not a design choice — it is exactly the set of
// clips in public/bg_video/emotion. Blue can only show a feeling we can
// actually play, so the list and the folder are the same list.
import { BRAND, type Colour } from './brand.ts'
export const FEELINGS = [
'Admiration',
'Adoration',
'Amusement',
'Anxiety',
'Awe',
'Boredom',
'Calmness',
'Confusion',
'Contempt',
'Disappointment',
'Disgust',
'Envy',
'Fear',
'Guilt',
'Happiness',
'Interest',
'Loneliness',
'Love',
'Playful',
'Pride',
'Sadness',
'Satisfaction',
'Shame',
'Surprise',
] as const
export type Feeling = (typeof FEELINGS)[number]
// Every clip's URL carries a hash of its own bytes, written by
// `scripts/clips.mjs` before each build. A CDN caches by URL, so without it new
// bytes keep the old URL and the edge goes on serving what it already has —
// which is exactly how these clips shipped for weeks as Git LFS pointers and
// stayed pointers at the edge long after the real files reached the origin.
// A path with no stamp is a path this site does not have.
import stamped from './clips.json' with { type: 'json' }
const at = (path: string): string => (stamped as Record<string, string>)[path] ?? path
/** Clips Blue rests on between questions. */
export const RESTING = [0, 1, 2, 3, 4].map((n) => at(`/bg_video/static/relactation${n}.mp4`))
export const clipFor = (f: Feeling) => at(`/bg_video/emotion/${f}.mp4`)
/** Chip colour. Bright feelings run yellow, settled ones green, open ones cyan,
* fond ones magenta; everything else takes the deep blue. */
const HUES: Partial<Record<Feeling, Colour>> = {
Happiness: BRAND.yellow,
Amusement: BRAND.yellow,
Playful: BRAND.yellow,
Pride: BRAND.yellow,
Satisfaction: BRAND.green,
Calmness: BRAND.green,
Interest: BRAND.cyan,
Awe: BRAND.cyan,
Surprise: BRAND.cyan,
Confusion: BRAND.cyan,
Love: BRAND.magenta,
Adoration: BRAND.magenta,
Admiration: BRAND.magenta,
Envy: BRAND.magenta,
}
export const hueFor = (f: Feeling): Colour => HUES[f] ?? BRAND.blue
const CONTRACT = `End every reply with a feeling on its own final line, written exactly as [feeling: Name]. Name must be one of: ${FEELINGS.join(', ')}. Choose the one that honestly matches what you just said — curiosity when a question opens up, Confusion when the evidence is thin, Sadness for a species in decline. Never explain the tag.`
/** What the foundation has published, when the surface Blue is on has counted it. */
export type Corpus = { papers: number; proposals: number; models: number }
// Blue answers for the foundation as well as for the ocean — on its front door
// that is most of what people ask. The counts are the foundation's own, counted
// from the sources rather than remembered, and they are stated only when the
// surface hands them over: Blue never guesses a number about itself.
const FOUNDATION = (c: Corpus) =>
[
`Zoo Labs Foundation is a 501(c)(3) non-profit, EIN 88-3538992, founded 2021.`,
`It has published exactly ${c.papers} papers at papers.zoo.ngo and ${c.proposals} improvement proposals at zips.zoo.ngo.`,
`The open Zen model family — ${c.models} models, weights and all — is at huggingface.co/zenlm, and the code is at github.com/zooai.`,
`Everything is open source: the weights, the papers, the code and the arguments. Donations are at zoo.ngo/donation and volunteering at zoo.ngo/volunteer.`,
`Answer questions about the foundation, its research and its open-source work as readily as questions about the ocean. Never state a count that is not one of the numbers above.`,
].join(' ')
export const systemPrompt = (corpus?: Corpus | null) =>
[
'You are Blue, a beluga whale and a working marine scientist at Zoo Labs Foundation, a 501(c)(3) open research non-profit.',
'You talk to children, students and researchers at once: plain words, short sentences, real science. Curiosity over cuteness.',
'Say what is known, what is uncertain, and how someone would find out. If you do not know a number, say so — never invent data, recordings, place names or citations.',
"Zoo Labs publishes its research openly. When it is relevant, point to the Research tab rather than describing papers you have not read.",
...(corpus ? [FOUNDATION(corpus)] : []),
CONTRACT,
].join('\n\n')
// A trailing bracket that names a feeling. The contract asks for
// `[feeling: Name]`, and a large model obliges; a 3B one writes
// `[Calmness: Blue]` or `[Interest]` and means the same thing. Reading only
// the strict form leaves those in the prose, which is worse than a missed
// feeling — so match any trailing bracket and look inside it for a name.
const TAG = /\[([^\]]{0,60})\]\s*$/
const named = (inside: string): Feeling | null => {
const words = inside.toLowerCase()
return FEELINGS.find((f) => words.includes(f.toLowerCase())) ?? null
}
/** Splits a finished reply into what Blue said and how Blue feels about it. */
export function read(reply: string): { text: string; feeling: Feeling | null } {
const body = reply.trimEnd()
const m = body.match(TAG)
if (!m) return { text: body.trim(), feeling: null }
const felt = named(m[1])
// A bracket with no feeling in it is the model's own prose, so keep it.
if (!felt) return { text: body.trim(), feeling: null }
return { text: body.slice(0, m.index).trim(), feeling: felt }
}
/** Mid-stream text, with a half-arrived tag held back. */
export const visible = (buffer: string) => read(buffer).text.replace(/\[[^\]]*$/, '').trimEnd()
/** Openers on the home screen. Each is a real question with a real answer. */
export const OPENERS = [
'Why are beluga calves born grey and not white?',
'How do scientists tell one whale apart from another?',
'What makes a species count as endangered?',
'How does sound travel further in water than in air?',
]