diff --git a/README.md b/README.md index fac9441a5..5ce77e031 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ curl -fsSL https://raw.githubusercontent.com/AgentSystemLabs/agent-office/main/i - **From your phone, too.** `/lite` is the office in 2D: every worker and what it's waiting on, its terminal with the keys a phone keyboard lacks, and the boards. The 3D office offers it on a phone or a slow computer. - **GitHub on the walls.** Issues and pull requests hang on cork boards. Hand an issue to a worker, queue tasks, give a worker its own git worktree and open its PR with one key (if one gets deleted behind the office's back, the worker waits at its desk until you rebuild it). One task can span several projects: the worker gets a worktree of each, and a PR in each that links the others. - **Agents that manage agents.** Every worker can list, hire, message and send home the others, through an `agent-office` MCP server (Claude Code, Codex, OpenCode) or the `office-workers` command. Ask one to "send everyone whose PR merged home" and it does, deleting their worktrees and branches unless they hold unpushed work. A worker that opens its pull request itself (`gh pr create`) shows it at its desk, and one the office missed can be told which is its own (`office-workers pr`). +- **Workers with lives of their own.** Each hire is dealt a persona for its floor, and when it has nothing to do it wanders the office — sits on the couch, watches the TV, plays the arcade, chats with coworkers in speech bubbles — each with its own temperament, voice and favourites, and a local Ollama model can even write their conversations and remember them. The moment work arrives, it hurries back to its desk. - **Together.** Voice, chat, screen sharing on the lounge TV and a shared whiteboard. - **Other maps.** Turn the whole building into a castle: sit on a throne of iron blades while your workers line up before you when they're done, send new ones off through the Hand of the King, and watch their beards grow long and grey as they toil. Send one home and the Kingsguard runs up from the dungeon, marches it down the stairs and throws it in a cell, where it starves, dies and rots down to a skeleton. Or into a space station in orbit, the Earth turning outside its windows: you run it from the captain's chair on the bridge, and a worker sent home is marched to the airlock and blown out into space, to drift off past the observation windows with everyone who went before it. Or make a map of your own, with its own way of seeing workers off in JSON ([docs/maps.md](docs/maps.md)). diff --git a/docs/features.md b/docs/features.md index f320d9b99..27b8aea45 100644 --- a/docs/features.md +++ b/docs/features.md @@ -59,6 +59,10 @@ Everything in the office, room by room. Back to the [README](../README.md). - **Pictures on the walls.** Press **F** (or **🖼️ Hang a picture** in the **☰** menu) and paste a link to any image online: a team photo, a diagram, a meme. Pick one of six frames, then aim at a wall and click to hang it. Scroll to size it first. Where it can't go, over a window or a board, the preview turns red. Everyone sees it right away, and it stays up across restarts. Look at a picture and press **E** for a closer look, or to move, edit or take it down. - **A whiteboard to draw on together.** A whiteboard on wheels stands out on the floor between the desks and the lounge. Press **E** at it (or **📝 Whiteboard** in the **☰** menu) and [Excalidraw](https://excalidraw.com) opens, with shapes, arrows, freehand, text and pasted pictures. Everyone on the floor who opens it draws on the same board at once: you see their strokes as they draw, their cursors with their names, and what they have selected. The top of the window shows who's drawing, and so does the hint at the board. Whatever is drawn shows on the whiteboard in the office for everyone walking past, and stays there across restarts. Each floor has its own. Press Esc once to finish what you're doing and again to close it. - **A bookshelf of the project's docs.** A bookshelf stands against the south wall, between the balcony doors and the window. Press **E** at it to read every Markdown file in the project: the README, the docs folder, each package's notes. Type in the filter box to narrow the list down. The letters only need to be in order, so `gs` finds `getting-started.md`, and each extra word narrows it again. Use **↑ ↓** and **Enter** to open one, and **Page Up/Down** to read on without leaving the box. Docs look the way GitHub shows them, with tables, code, callouts, task lists and the project's own pictures. A link to another doc opens it right there, **☰ Contents** jumps to any heading, and the shelf reopens on the doc you last read. While you read, your character holds an open book, turning a page now and then. Everyone else sees it, along with what you're reading under your name tag. A page turns with a soft swish when you open a doc or scroll a screenful. Turn the swish off with the 🔈 at the top of the bookshelf, or under **⚙️ → Sound & voice**. +- **Workers with a personality of their own.** Every worker is dealt a persona from the floor's deck — The Pragmatist, The Skeptic, The Night Owl and nineteen more — seeded by the project and the desk, so the same hire always gets the same character and a fresh hire is someone new. The persona goes into the agent's own brief (Claude Code's system prompt, an instructions file for OpenCode, the first prompt for the rest), so it colours how the worker actually works and answers, and its name shows in the Workers panel. +- **Workers that wander when they've nothing to do.** An idle worker doesn't sit at its desk forever: after a spell it hops down and ambles off to the couch (where it really sits, three to a couch), the beanbags, the TV, the jukebox, the arcade cabinet, the whiteboard, the bookshelf, the elevator lobby or the DJ booth, lingering a while and then heading back. Who goes where, how fast and how long is dealt from a per-worker character — sociability, energy, curiosity, patience, mischief, a walking pace, a tone of voice (dry, warm, hyper, gloomy, formal, chaotic), a quirk (humming, muttering, sighing…) and a taste for some haunts over others — all seeded from the floor, desk and worker id, so no two workers behave or sound alike. Needs for fun, company and calm creep up over time and pull each one toward the spot that answers them. Speech bubbles over their heads carry their lines in their own voice, and walking past one can earn you a hello — or one from a coworker whose path you crossed. When someone finishes a task, whoever's nearby hears about it and the cheerful ones hop. Nobody stands inside anybody else: spots are claimed, the floor plan keeps them apart, and a nudge sorts out whatever still crosses. The moment a worker has work again — or needs you — it drops whatever it was doing and goes straight back to its desk. +- **Workers that talk to each other.** Two idle workers near each other stop for a chat: they walk to a point between them, face each other and trade lines, each in its own voice and persona. How often they strike up, who they pick (friends likelier than rivals — and friends call from farther away, rivals only when close) and how long it runs all follow their characters, and every chat moves their relationship — repeated chats make friends, clashing tempers make rivals, and the next greeting changes ("there you are" vs "oh. you."). +- **The office mind.** Chats can be written by a small local model instead of the built-in lines: an Ollama server on the office machine (`llama3.2:3b` by default; point it elsewhere with `OFFICE_MIND_URL` and `OFFICE_MIND_MODEL`) writes each pair's whole script while they walk to meet, in character, and afterwards keeps a short note of what mattered — how it felt about the other worker, a topic to bring up next time — in `.agent-office/minds.json`, so workers remember who they talked to and what was said. A worker that finishes a task also writes down what it took away, and anything worth everyone's attention (a bug found, a convention, a warning) goes on a shared list for its floor that any chat can pick up. Each note gets an embedding from Ollama's `nomic-embed-text` (override with `OFFICE_MIND_EMBED`), so when two workers meet the mind recalls the notes most relevant to what they're about — partner, last task — rather than just the latest ones, and older notes count for less as they age. Once a worker's memory piles up it gets distilled down to the few things that matter. Relationships cool on their own too: pairs that stop meeting drift back toward neutral, and the ones that remain are remembered in `minds.json`, so friends and rivals survive restarts. The mind only ever writes a line or two: it never touches code, tools or the repo, and whenever Ollama isn't running the procedural chatter covers silently. On a CPU-only machine a script takes a few seconds to tens of seconds, so early turns may still be procedural; the model stays warm in memory between chats. - **An office dog.** Every floor has a dog. It naps under the desks of workers who are busy, trots after people for a while, sniffs around and hangs out on the lounge rug. When a worker needs input, it drops everything, runs to that desk and barks until someone opens that worker's terminal, so you can see from across the room who's waiting. It stays there until the worker is answered. Walk up and press **E** to pet it, and everyone sees it wag. Name it under **⚙️** → *Office dog*, and pick its breed (a pup, a corgi, a dachshund, a pug, a shiba or a pomeranian) and its coat there too: they're for everyone on that floor. Until someone picks them, each floor's dog is a breed and coat of its own. - **Sent home with a box.** A worker you send home (**X** at its desk) doesn't just vanish. Its light goes out, its face falls, and its things go into a cardboard box: a plant, a framed photo, its mug and a rubber duck. Its laptop snaps shut, and it hops off its chair and trudges out with the box in its arms, out the exit door, down the steps and off along the sidewalk. Floors above the bottom one have no exit door, so there it walks out onto the balcony instead, climbs up on the railing and jumps: its parachute pops open and it circles down onto the lot out front, box and all, then walks off along the sidewalk. The desk is free straight away, so you can hire someone new while the last one is still on the way out. In the castle it goes to the dungeon instead and on the space station out of the airlock, and a map of your own can script its own way of seeing workers off ([Sending workers home](maps.md#sending-workers-home)). - **Voice.** Browser-to-browser WebRTC voice. Volume depends on how close you stand, but people are never fully silent. **V** joins, and once you're in, **V** is push to talk: hold it to talk, and you're muted when you let go. **M** mutes and unmutes. Pick **Push to talk** under **⚙️** to join muted, or keep an open mic. Leave voice from the **☰** menu. diff --git a/src/client/core/loop.ts b/src/client/core/loop.ts index d78dfad8d..c4b3e02ff 100644 --- a/src/client/core/loop.ts +++ b/src/client/core/loop.ts @@ -152,7 +152,7 @@ export function installLoop(ctx: Ctx, core: CoreState, parts: Pick r.person.root.position), ...departures.positions(), ...sendoffs.positions(), ...arrivals.positions(), ...(court?.positions() ?? [])]); + ctx.world().update(t, dt, [player.pos, ...[...remotes.values()].map((r) => r.person.root.position), ...departures.positions(), ...sendoffs.positions(), ...arrivals.positions(), ...(court?.positions() ?? []), ...parts.views.wander.positions()]); if (ctx.inOffice()) { office.stack.update(dt, [{ x: player.pos.x, y: player.pos.y, z: player.pos.z, grip: ctx.view.grip() }, ...[...remotes.values()].map((r) => ({ x: r.person.root.position.x, y: r.person.root.position.y, z: r.person.root.position.z, grip: r.grip }))], camera.position); office.jukebox.update(t, dt, sound.beat()); diff --git a/src/client/core/maps.ts b/src/client/core/maps.ts index b9adbe085..9f64e792e 100644 --- a/src/client/core/maps.ts +++ b/src/client/core/maps.ts @@ -52,6 +52,7 @@ export function installMaps(ctx: Ctx, core: CoreState, parts: MapsParts) { views.departures.clear(); views.sendoffs.clear(); views.arrivals.clear(); + views.wander.clear(); parts.telescope.exit(); ctx.activities.stopAll('map'); parts.walking.stopWalkingTo(); diff --git a/src/client/features/workers/ambient.ts b/src/client/features/workers/ambient.ts new file mode 100644 index 000000000..7943a82eb --- /dev/null +++ b/src/client/features/workers/ambient.ts @@ -0,0 +1,141 @@ +// The ambient half of a wanderer's life (see features/workers/wander.ts): the lines over its head, +// where it goes when it fancies a change, what it does when it gets there — and the little social +// moments around it: a hello when paths cross, and congratulations when someone finishes a task. +import type { NavGrid, Pt } from '../../../shared/nav'; +import { PERSONA_LINES, personaFor } from '../../../shared/personas'; +import type { WorkerInfo } from '../../../shared/protocol'; +import { + applyVisit, pick, pickKind, spotLine, styled, type SpotKind, +} from '../../../shared/traits'; +import { SPOTS, TONE_BG } from './spots'; +import { spotTaken } from './crowd'; +import type { Roamer } from './roamer'; +import { store } from '../../state'; +import { disposeSprite, textSprite } from '../../world/toon'; + +/** How close someone has to be, still out and about, to hear the news. */ +const CHEER_REACH = 7; +/** Two walkers this close (and this often at most) say hi without stopping. */ +const GREET_REACH = 1.5; +const GREET_EVERY = 25; +/** Seconds a pass-by hello stays up. */ +const GREET_SECS = 1.6; +const LINE_SECS = 2.6; + +/** What it says when it clocks a coworker walking past. */ +const GREET = ['¡eh!', 'buenas', 'cómo vas', '¿todo bien?', 'qué tal', 'ahí va la competencia', '*choca el hombro*']; +/** What it says when someone nearby just finished something. */ +const CHEER = ['¡bien hecho!', '¡por fin!', 'ese era tuyo', '¡qué buena!', 'merecido', '¡así se hace!', 'cuéntame cómo lo lograste']; + +interface Deps { + roamers: Map; + nav: () => NavGrid; + worker: (id: string) => WorkerInfo | undefined; + /** The wander clock, for bubble lifetimes and greet cooldowns. */ + now: () => number; + stayFor: (c: Roamer, kind: SpotKind | undefined) => number; + walkTo: (c: Roamer, target: Pt | null) => void; +} + +export class Ambient { + constructor(private d: Deps) {} + + /** The lines its persona says (empty for shells and persona-less desks). */ + personaLines(c: Roamer): readonly string[] { + const w = this.d.worker(c.id); + const persona = w?.kind === 'agent' ? personaFor(store.project?.dir, c.desk.def.id) : undefined; + return (persona && PERSONA_LINES[persona.id]) || []; + } + + /** A line over its head for a turn or so, in its own colours. */ + say(c: Roamer, text: string, secs = LINE_SECS) { + this.dropBubble(c); + const sprite = textSprite(text, { bg: TONE_BG[c.traits.tone], color: '#fffaf3', size: 30 }); + sprite.position.set(0, 2.1, 0); + c.model.root.add(sprite); + c.bubble = { sprite, until: this.d.now() + secs }; + } + + dropBubble(c: Roamer) { + if (!c.bubble) return; + c.model.root.remove(c.bubble.sprite); + disposeSprite(c.bubble.sprite); + delete c.bubble; + } + + /** It finished a task: whoever's out and about nearby says so, and the cheerful ones hop. */ + cheerFor(finished: Roamer) { + const fp = finished.model.root.position; + for (const c of this.d.roamers.values()) { + if (c === finished || c.state === 'seated' || c.state === 'chat' || c.bubble) continue; + if (c.model.root.position.distanceTo(fp) > CHEER_REACH) continue; + if (c.rng() > 0.25 + 0.6 * c.traits.sociability) continue; + this.say(c, styled(c.traits, pick(c.rng, CHEER), c.rng)); + if (c.rng() < 0.3 + 0.5 * c.traits.energy) c.model.celebrate(); + } + } + + /** + * Two workers' paths crossed: the keener one says hi on the move — no stopping, no hard + * feelings, just an office being an office. At most once in a while per pair. + */ + passBy(a: Roamer, b: Roamer) { + const now = this.d.now(); + if (now - a.lastGreet < GREET_EVERY || now - b.lastGreet < GREET_EVERY) return; + if (a.bubble || b.bubble || a.state !== 'walk' || b.state !== 'walk') return; + const sociable = (a.traits.sociability + b.traits.sociability) / 2; + if (Math.max(a.rng(), b.rng()) > 0.2 + 0.7 * sociable) return; + const speaker = a.traits.sociability >= b.traits.sociability ? a : b; + this.say(speaker, styled(speaker.traits, pick(speaker.rng, GREET), speaker.rng), GREET_SECS); + speaker.lastGreet = now; + } + + /** Idle long enough: hops down and walks to a haunt it fancies right now. */ + goOut(c: Roamer) { + const nav = this.d.nav(); + // Somewhere it isn't already, nobody else has claimed, and that the floor plan can reach. + const here = c.state === 'seated' ? null : c.spot; + const options = SPOTS.filter( + (s) => nav.walkable(s.x, s.z) + && !(here && Math.hypot(s.x - here.x, s.z - here.z) < 1.2) + && !spotTaken(this.d.roamers.values(), c, s.x, s.z), + ); + if (!options.length) { + c.timer = 10 + c.rng() * 50; + return; + } + const kinds = [...new Set(options.map((s) => s.kind!))]; + const kind = pickKind(c.traits, c.needs, c.rng, c.lastKind, kinds); + const ofKind = options.filter((s) => s.kind === kind); + const spot = ofKind[Math.floor(c.rng() * ofKind.length)] ?? options[0]; + // Each worker stands or sits its own little way: a jittered spot nobody else claimed (a tighter + // jitter on furniture, where the places are closer together), so two at the couch don't overlap. + const spread = spot.seat !== undefined ? 0.4 : 0.9; + let x = spot.x; + let z = spot.z; + for (let tries = 0; tries < 4; tries++) { + // Keep the last walkable cell even if it's taken: the nav grid can collapse different jitters + // onto the same point, and separate() sorts out whoever ends up too close. + const [jx, jz] = nav.nearestWalkable([spot.x + (c.rng() - 0.5) * spread, spot.z + (c.rng() - 0.5) * spread]); + x = jx; + z = jz; + if (!spotTaken(this.d.roamers.values(), c, jx, jz)) break; + } + c.spot = { ...spot, x, z }; + c.timer = this.d.stayFor(c, spot.kind); + this.d.walkTo(c, [x, z]); + } + + /** It's reached a haunt: eases a need, says something about the place, and reacts the way it would. */ + arrive(c: Roamer) { + const kind = c.spot?.kind; + if (kind) { + applyVisit(c.needs, kind); + if (c.rng() < 0.55) this.say(c, spotLine(c.traits, kind, c.rng)); + // The arcade and the DJ booth get real excitement; everything else a happy hop now and then. + if (kind === 'arcade' || kind === 'dj' || kind === 'jukebox') c.model.celebrate(); + else if (c.rng() < 0.3 + 0.3 * c.traits.energy) c.model.celebrate(); + } + c.asideIn = 3 + c.rng() * 5; + } +} diff --git a/src/client/features/workers/crowd.ts b/src/client/features/workers/crowd.ts new file mode 100644 index 000000000..e414d0ff0 --- /dev/null +++ b/src/client/features/workers/crowd.ts @@ -0,0 +1,52 @@ +// Keeping wanderers from standing inside each other (see features/workers/wander.ts): who has a +// haunt claimed, and the per-tick nudge that sorts out whatever the nav grid collapsed together. +import type * as THREE from 'three'; + +/** The bits of a wanderer the crowd rules need (wander.ts's Roamer satisfies this). */ +export interface RoamerLike { + id: string; + state: string; + /** Where it's off to or lingering, if anywhere. */ + spot: { x: number; z: number } | null; + model: { root: { position: THREE.Vector3 } }; +} + +/** Whether another worker out and about already has `x, z` as its haunt (or is on its way there). */ +export function spotTaken(roamers: Iterable, self: RoamerLike, x: number, z: number): boolean { + for (const o of roamers) { + if (o === self || !o.spot) continue; + if (o.state !== 'walk' && o.state !== 'linger' && o.state !== 'chat' && o.state !== 'down') continue; + if (Math.hypot(o.spot.x - x, o.spot.z - z) < 1.0) return true; + } + return false; +} + +/** + * Nobody stands inside anybody else: whatever the nav grid collapsed together — a shared couch + * place, a chat rendezvous on one cell, paths crossing — gets nudged apart until a body's width. + * Pairs that pass within `greet` of each other get a hello first (see ambient.ts passBy). + */ +export function separate(roamers: Iterable, onPass?: (a: T, b: T) => void, greet = 1.5) { + const out = [...roamers].filter((c) => c.state !== 'seated'); + for (let i = 0; i < out.length; i++) { + for (let j = i + 1; j < out.length; j++) { + const a = out[i]; + const b = out[j]; + const pa = a.model.root.position; + const pb = b.model.root.position; + const dx = pb.x - pa.x; + const dz = pb.z - pa.z; + const d = Math.hypot(dx, dz); + if (d < greet) onPass?.(a, b); + if (d >= 0.55) continue; + const push = (0.55 - d) / 2; + // Dead-centre overlaps pick a stable direction from their ids, so they don't vibrate. + const ux = d > 1e-4 ? dx / d : a.id < b.id ? 1 : -1; + const uz = d > 1e-4 ? dz / d : 0; + pa.x -= ux * push; + pa.z -= uz * push; + pb.x += ux * push; + pb.z += uz * push; + } + } +} diff --git a/src/client/features/workers/mind-client.ts b/src/client/features/workers/mind-client.ts new file mode 100644 index 000000000..336af9589 --- /dev/null +++ b/src/client/features/workers/mind-client.ts @@ -0,0 +1,144 @@ +// The client half of the office mind (see server/mind.ts): asks /api/mind for a chat script while +// two wanderers walk to their rendezvous, hands each its lines when the chat starts, and reports +// back what was said so the mind remembers. The procedural lines in wander.ts cover any miss. +import { personaFor } from '../../../shared/personas'; +import type { WorkerInfo } from '../../../shared/protocol'; +import type { Traits } from '../../../shared/traits'; +import { store } from '../../state'; + +interface MindRoamer { + id: string; + traits: Traits; + desk: { def: { id: string } }; +} + +interface Script { + aId: string; + a: string[]; + b: string[]; +} + +export class MindChats { + /** Scripts on their way or in hand, per pair. */ + private readonly minds = new Map(); + /** Affinity updates waiting for their one batched save. */ + private readonly pendingAffinity = new Map(); + private affinityTimer?: ReturnType; + + constructor( + private worker: (id: string) => WorkerInfo | undefined, + private floor: () => string, + ) {} + + private key(a: string, b: string) { + return a < b ? `${a}|${b}` : `${b}|${a}`; + } + + /** What the mind is told about a worker: who it is, how it sounds, and what it remembers (the server keeps that). */ + private actor(c: MindRoamer) { + const w = this.worker(c.id); + const persona = w?.kind === 'agent' ? personaFor(store.project?.dir, c.desk.def.id) : undefined; + return { + id: c.id, + persona: persona?.name, + tone: c.traits.tone, + quirk: c.traits.quirk, + sociability: c.traits.sociability, + energy: c.traits.energy, + mischief: c.traits.mischief, + task: w?.prompt?.slice(0, 200), + memory: [], + }; + } + + /** Asks for this pair's script; the first caller wins, and a later chat gets a fresh ask. */ + ask(a: MindRoamer, b: MindRoamer, turns: number) { + const k = this.key(a.id, b.id); + if (this.minds.has(k)) return; + const rec: { script?: Script | null } = {}; + this.minds.set(k, rec); + fetch('/api/mind', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ floor: this.floor(), op: 'chat', turns, a: this.actor(a), b: this.actor(b) }), + }) + .then((r) => (r.ok ? r.json() : null)) + .then((j) => { + rec.script = j && Array.isArray(j.a) && Array.isArray(j.b) ? { aId: a.id, a: j.a as string[], b: j.b as string[] } : null; + }) + .catch(() => { + rec.script = null; + }); + } + + /** The pair's script once it lands: `aId` says whose lines `a` are. Undefined while it's still being written. */ + script(aId: string, bId: string): Script | null | undefined { + return this.minds.get(this.key(aId, bId))?.script; + } + + /** A worker finished a task: the mind writes down what it took away (fire-and-forget). */ + learn(c: MindRoamer, task: string) { + fetch('/api/mind', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ floor: this.floor(), op: 'learn', a: this.actor(c), task }), + }).catch(() => {}); + } + + /** Tells the mind what `c` said to `p`, so it remembers them next time (fire-and-forget). */ + reflect(c: MindRoamer, p: MindRoamer, said: string[]) { + if (!said.length) return; + fetch('/api/mind', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ floor: this.floor(), op: 'reflect', a: this.actor(c), b: this.actor(p), saidA: said, saidB: [] }), + }).catch(() => {}); + } + + /** The floor's remembered affinities, once — relationships survive restarts and visitors. */ + loadAffinity(): Promise | null> { + return fetch('/api/mind', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ floor: this.floor(), op: 'affinity' }), + }) + .then((r) => (r.ok ? r.json() : null)) + .then((j) => (j && typeof j.affinity === 'object' && j.affinity ? (j.affinity as Record) : null)) + .catch(() => null); + } + + /** Fetch the floor's remembered affinities into `into`: relationships survive restarts and visitors. */ + primeAffinity(into: Map) { + void this.loadAffinity().then((saved) => { + if (!saved) return; + for (const [k, v] of Object.entries(saved)) { + if (typeof v === 'number' && Number.isFinite(v)) into.set(k, Math.max(-1, Math.min(1, v))); + } + }); + } + + /** Queue affinity updates; they go up in one request a few seconds later, and go back in the queue if it's lost. */ + saveAffinity(pairs: Record) { + for (const [k, v] of Object.entries(pairs)) this.pendingAffinity.set(k, v); + if (this.affinityTimer) return; + this.affinityTimer = setTimeout(() => { + this.affinityTimer = undefined; + if (!this.pendingAffinity.size) return; + const pairs = Object.fromEntries(this.pendingAffinity); + this.pendingAffinity.clear(); + fetch('/api/mind', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ floor: this.floor(), op: 'affinity', pairs }), + }).catch(() => { + for (const [k, v] of Object.entries(pairs)) this.pendingAffinity.set(k, v); + }); + }, 4000); + } + + /** A pair's script is done being useful; the next chat asks for a new one. */ + forget(aId: string, bId: string) { + setTimeout(() => this.minds.delete(this.key(aId, bId)), 30_000); + } +} + diff --git a/src/client/features/workers/roamer.ts b/src/client/features/workers/roamer.ts new file mode 100644 index 000000000..0002947da --- /dev/null +++ b/src/client/features/workers/roamer.ts @@ -0,0 +1,48 @@ +// What a wanderer is (see features/workers/wander.ts): its character, its needs, where it is in +// its little life. Its own file so ambient.ts, crowd.ts and the rest can name it without a cycle. +import * as THREE from 'three'; +import type { Pt } from '../../../shared/nav'; +import type { Needs, Rng, SpotKind, Traits } from '../../../shared/traits'; +import type { WorkerInfo } from '../../../shared/protocol'; +import type { Spot } from './spots'; +import type { Worker } from '../../world/character'; +import type { DeskView } from '../../world/types'; + +export type State = 'seated' | 'down' | 'walk' | 'linger' | 'chat' | 'up'; + +export interface Roamer { + id: string; + model: Worker; + desk: DeskView; + /** Who it is: dealt from a seed when it's hired, and its own generator from then on. */ + traits: Traits; + rng: Rng; + needs: Needs; + state: State; + /** Where it's off to: a spot in SPOTS, a chat rendezvous, or its seat. */ + spot: Spot | null; + /** Walking to a chat rendezvous: who it's meeting (cleared on arrival). */ + chatWith?: string; + /** Mid-chat: who it's with, when it started (synced with the partner's), the last turn shown, how many turns, how long it's waited for the partner, and what it said. */ + chat?: { partner: string; start: number; seen: number; turns: number; wait: number; said: string[] }; + /** A line over its head right now, and when it comes down. */ + bubble?: { sprite: THREE.Sprite; until: number }; + /** Seconds until its next aside (a mutter, a hum) while it's standing about. */ + asideIn: number; + /** The kind of haunt it was at last, so it doesn't just go back. */ + lastKind: SpotKind | null; + /** How seated it is, 0 standing to 1 sunk into the furniture (see the end of step). */ + sit: number; + way: Pt[]; + next: number; + hop: number; + from: THREE.Vector3; + heading: number; + stepIn: number; + /** Seconds it has left before it goes (seated) or comes home (lingering). */ + timer: number; + /** Its status last tick, so a finished task can be told to the mind. */ + lastStatus?: string; + /** When it last said hi to someone it passed (so passers-by don't greet every frame). */ + lastGreet: number; +} diff --git a/src/client/features/workers/spots.ts b/src/client/features/workers/spots.ts new file mode 100644 index 000000000..00dc4b0c2 --- /dev/null +++ b/src/client/features/workers/spots.ts @@ -0,0 +1,44 @@ +import { BOOKSHELF, CABINET, DJ_BOOTH, ELEVATOR, ELEVATOR_FRONT, FLOOR, JUKEBOX, SEATING, TV, WHITEBOARD, seatPlace } from '../../../shared/layout'; +import type { SpotKind, Tone } from '../../../shared/traits'; + +/* + * Where wanderers hang out (see features/workers/wander.ts): the lounge seats — one spot per place + * on each couch and beanbag, so a couch really seats three — and standing spots at the office's + * things. A spot with `seat` is furniture: the worker walks to it and sits down. + */ + +export interface Spot { + x: number; + z: number; + /** The way it faces while it's there (0 = +z). */ + rotY: number; + /** What kind of haunt it is (absent for a chat rendezvous). */ + kind?: SpotKind; + /** Sitting furniture: how high the seat is (the SeatDef's hips), absent for standing spots. */ + seat?: number; +} + +/** Where it hangs out: the lounge seats, and standing spots at the office's things. */ +export const SPOTS: Spot[] = [ + // The couch and the beanbags either side of the lounge: a spot per place, facing the TV. + ...SEATING.filter((s) => s.y === 0 && !s.roof && !s.bar).flatMap((s) => + s.places.map((_, i) => { + const p = seatPlace(s, i); + return { x: p.x, z: p.z, rotY: p.rotY, kind: 'couch' as const, seat: p.hips }; + }), + ), + // In front of the TV, the jukebox and the arcade cabinet, on the east wall. + { x: FLOOR.maxX - 1.5, z: TV.z, rotY: Math.PI / 2, kind: 'tv' }, + { x: FLOOR.maxX - 1.7, z: JUKEBOX.z, rotY: Math.PI / 2, kind: 'jukebox' }, + { x: FLOOR.maxX - 1.7, z: CABINET.z, rotY: Math.PI / 2, kind: 'arcade' }, + // At the whiteboard, the bookshelf, the elevator and the DJ booth. + { x: WHITEBOARD.x, z: WHITEBOARD.z + 1.2, rotY: Math.PI, kind: 'board' }, + { x: BOOKSHELF.x, z: BOOKSHELF.z - 1.3, rotY: 0, kind: 'shelf' }, + { x: ELEVATOR.x, z: ELEVATOR_FRONT + 1.0, rotY: Math.PI, kind: 'elevator' }, + { x: DJ_BOOTH.x, z: DJ_BOOTH.z + 1.4, rotY: Math.PI, kind: 'dj' }, +]; + +/** Bubble colour per tone of voice, so you can tell who's talking at a glance. */ +export const TONE_BG: Record = { + dry: '#2b2d42', warm: '#7a3e2f', hyper: '#b5179e', gloomy: '#3a4a5a', formal: '#1d3557', chaotic: '#386641', +}; diff --git a/src/client/features/workers/views.ts b/src/client/features/workers/views.ts index 49bf6732b..6ba59c711 100644 --- a/src/client/features/workers/views.ts +++ b/src/client/features/workers/views.ts @@ -32,6 +32,7 @@ import { Jail } from './jail'; import { Laptop } from './laptop'; import { Arrivals, Departures } from './leaving'; import { Sendoffs } from './sendhome'; +import { Wander } from './wander'; // The kinds of thing you can use that this defines (see InteractKinds in world/types.ts). declare module '../../world/types' { @@ -102,6 +103,8 @@ export function installWorkerViews(ctx: Ctx, core: CoreState, parts: WorkerViews (x, y, z) => sound.stepAt(x, z, y), () => ctx.world().ways, ); + // In the office, a worker with nothing to do gets up and wanders about (the couch, the TV, the arcade…). + const wander = new Wander(office.group, () => ctx.world().nav, groundHere, (x, y, z) => sound.stepAt(x, z, y), (id) => store.workers.get(id), () => player.pos); /** Set while a floor's workers arrive with it (a welcome, an elevator ride): they're in their seats already. */ let seatedAlready = false; /** A floor arriving (a welcome, a floor.enter): nobody's still leaving or coming in, and its workers are seated already. */ @@ -135,6 +138,8 @@ export function installWorkerViews(ctx: Ctx, core: CoreState, parts: WorkerViews if (desk.def.room && !seatedAlready) arrivals.add(model, desk); // In the castle, a worker at the tables gets up and walks about (see Court): a new one runs in to its seat. else if (court && inCourt(w)) court.add(w.id, model, desk, seatedAlready ? undefined : cameFrom(w)); + // In the office, a worker at an ordinary desk or bean bag does its own wandering (see Wander). + else if (inOffice() && !desk.def.station && !desk.def.room) wander.add(w.id, model, desk); const laptop = new Laptop(world.device); desk.laptopAnchor.add(laptop.root); noOutline(desk.group); @@ -175,8 +180,8 @@ export function installWorkerViews(ctx: Ctx, core: CoreState, parts: WorkerViews if (store.workers.has(id)) continue; arrivals.forget(v.model); const desk = world.desks.get(v.deskId); - // Up and about in the castle: it sets off from where it's standing. - const up = court?.release(id); + // Up and about (the castle's court, or wandering the office): it sets off from where it's standing. + const up = court?.release(id) ?? wander.release(id); // Sent home: it packs up and walks out, and the seat shows as free once it's up (see departures), or // on a map with its own way of seeing workers off, that (the castle's dungeon, for one it locks up). const send = plan().sendHome; @@ -314,6 +319,7 @@ export function installWorkerViews(ctx: Ctx, core: CoreState, parts: WorkerViews adrift.update(dt, t, camera.position); } arrivals.update(dt); + wander.update(dt); parts.worlds.court()?.update(dt); }); @@ -398,7 +404,7 @@ export function installWorkerViews(ctx: Ctx, core: CoreState, parts: WorkerViews function burstOver(deskId: string, n: number) { // Up and about in the castle: over wherever it is. const w = store.workerAtDesk(deskId); - const v = w && parts.worlds.court()?.away(w.id) ? workerViews.get(w.id) : undefined; + const v = w && (parts.worlds.court()?.away(w.id) || wander.away(w.id)) ? workerViews.get(w.id) : undefined; if (v) { const at = v.model.root.position; return confetti.burst(at.x, at.y + 2.1, at.z, n); @@ -433,6 +439,7 @@ export function installWorkerViews(ctx: Ctx, core: CoreState, parts: WorkerViews }, syncWorkers, syncWorkersSeated, + wander, syncJail, syncPlan, dressUp, diff --git a/src/client/features/workers/wander.ts b/src/client/features/workers/wander.ts new file mode 100644 index 000000000..42d947f84 --- /dev/null +++ b/src/client/features/workers/wander.ts @@ -0,0 +1,506 @@ +import * as THREE from 'three'; +import type { NavGrid, Pt } from '../../../shared/nav'; +import type { WorkerInfo } from '../../../shared/protocol'; +import { isBusy } from '../../../shared/status'; +import { + chatLine, chatOutcome, freshNeeds, makeRng, mutter, pick, relation, spotLine, styled, tickNeeds, + type Needs, type Rng, type SpotKind, type Traits, traitsFor, +} from '../../../shared/traits'; +import type { Roamer } from './roamer'; +import { Ambient } from './ambient'; +import { MindChats } from './mind-client'; +import { separate } from './crowd'; +import { store } from '../../state'; +import type { Worker } from '../../world/character'; +import type { DeskView } from '../../world/types'; + +/* + * Office workers with nothing to do getting up and living a little. Every worker is dealt its own + * character (shared/traits.ts) from a seed of floor + desk + its own id, so a fresh hire is always a + * different person: how restless, how sociable, how patient, how fast it walks, how it talks, where + * it likes to hang out, and a small quirk. Idle workers build up needs (fun, company, calm), pick a + * haunt that answers them, do something there, mutter to themselves, bump into each other, chat in + * their own voice (and their persona's), and remember who they get on with. The moment a worker has + * work again (or needs you) it turns for home. + */ + +/** Walking pace (m/s). */ +const WALK = 2.4; +/** Seconds hopping down off its seat, or up onto it. */ +const HOP = 0.5; +/** A worker's feet are this far above its origin (see features/workers/leaving.ts). */ +const FEET = 0.07; +/** Base waits (s); each worker's energy and patience stretch or shrink them. */ +const LEAVE_MIN = 10; +const LEAVE_SPAN = 50; +const STAY_MIN = 8; +const STAY_SPAN = 28; +/** How often it looks around for someone to stop and chat with, and how far apart they can be. */ +const CHAT_EVERY = 7; +const CHAT_REACH = 20; +/** Seconds each line stays up. */ +const LINE_SECS = 2.6; + +const wrap = (a: number) => Math.atan2(Math.sin(a), Math.cos(a)); +/** Frame-rate independent smoothing: the same feel at 30, 60 or 144 fps. */ +const smooth = (rate: number, dt: number) => 1 - Math.exp(-rate * dt); +/** How fast it turns while walking (rad/s), in its hops, and facing its spot; and how fast its feet find the floor. */ +const TURN_RATE = 11; +const HOP_TURN = 10; +const FACE_RATE = 8; +const GROUND_RATE = 14; + +/** What it says when it clocks you wandering past. */ +const HELLO = ['¡hola!', 'tú otra vez', 'hola, vecino', '*saluda*', '¿necesitas algo?', 'qué bonito día']; + +export class Wander { + private roamers = new Map(); + private readonly tmp = new THREE.Vector3(); + /** Where everyone out and about is standing, for whatever asks (the same list each time). */ + private readonly out: THREE.Vector3[] = []; + /** How each pair feels about the other, -1 (rivals) to 1 (friends), remembered while they're here. */ + private readonly affinity = new Map(); + /** The office mind's scripts per pair (see mind-client.ts); procedural lines cover any miss. */ + private readonly mind: MindChats; + /** Lines, haunts and the little social moments (see ambient.ts). */ + private readonly ambient: Ambient; + private clock = 0; + private chatIn = CHAT_EVERY; + + constructor( + private parent: THREE.Object3D, + private nav: () => NavGrid, + /** The top of whatever is underfoot at (x, z) for feet at `y`. */ + private ground: (x: number, z: number, y: number) => number, + private footstep: (x: number, y: number, z: number) => void, + private worker: (id: string) => WorkerInfo | undefined, + /** Where you are, so a worker out about can say hi (optional). */ + private playerAt?: () => { x: number; y: number; z: number }, + ) { + this.mind = new MindChats(worker, () => store.project?.dir ?? ''); + this.mind.primeAffinity(this.affinity); + this.ambient = new Ambient({ + roamers: this.roamers, + nav: this.nav, + worker: this.worker, + now: () => this.clock, + stayFor: (c, kind) => this.stayFor(c, kind), + walkTo: (c, target) => this.walkTo(c, target), + }); + } + + /** A worker sitting at `desk` now: it's dealt a character and starts its own count down to wandering off. */ + add(id: string, model: Worker, desk: DeskView) { + // Floor + desk + the worker's own id: each hire, even at the same desk, is someone new. + const traits = traitsFor(`${store.project?.dir ?? ''}|${desk.def.id}|${id}`); + const rng = makeRng(traits.seed ^ 0x9e3779b9); + const c: Roamer = { + id, model, desk, traits, rng, needs: freshNeeds(rng), state: 'seated', spot: null, asideIn: 0, lastKind: null, sit: 0, + way: [], next: 0, hop: 0, from: new THREE.Vector3(), heading: 0, stepIn: 0, timer: 0, lastGreet: 0, + }; + c.timer = this.leaveWait(c); + this.roamers.set(id, c); + } + + /** The character a worker was dealt (for panels), if it's here. */ + traitsOf(id: string): Traits | undefined { + return this.roamers.get(id)?.traits; + } + + /** Stops `id` wandering about (it's gone, or being sent home), leaving its model wherever it is. Where it stood, if it was out. */ + release(id: string): Pt | undefined { + const c = this.roamers.get(id); + if (!c) return undefined; + this.roamers.delete(id); + c.model.walking = false; + c.model.gait = 1; + this.ambient.dropBubble(c); + if (c.state === 'seated') return undefined; + const p = c.model.root.position; + return [p.x, p.z]; + } + + /** Whether `id` is up out of its seat. */ + away(id: string): boolean { + const c = this.roamers.get(id); + return !!c && c.state !== 'seated'; + } + + /** Everyone out and about, for the doors to open (the same list each time, to save making one a frame). */ + positions(): THREE.Vector3[] { + this.out.length = 0; + for (const c of this.roamers.values()) if (c.state !== 'seated') this.out.push(c.model.root.position); + return this.out; + } + + /** The map changed or the floor's gone: nobody's wandering any more (their models are someone else's problem now). */ + clear() { + this.roamers.clear(); + this.affinity.clear(); + } + + /** Whether it has nothing on: idle or done, awake, here, and not in a meeting. */ + private free(c: Roamer): boolean { + const w = this.worker(c.id); + return !!w && !isBusy(w.status) && w.status !== 'exited' && w.status !== 'offline' && !w.lost && !w.meeting; + } + + /** How long it sits at its desk before getting up: restless workers don't last long. */ + private leaveWait(c: Roamer): number { + return (LEAVE_MIN + c.rng() * LEAVE_SPAN) * (1.5 - c.traits.energy); + } + + /** How long it stays at a haunt: patient workers stay longer, and an arcade or the DJ booth holds people. */ + private stayFor(c: Roamer, kind: SpotKind | undefined): number { + const sticky = kind === 'arcade' || kind === 'dj' ? 1.3 : 1; + return (STAY_MIN + c.rng() * STAY_SPAN) * (0.6 + c.traits.patience * 0.8) * sticky; + } + + private key(a: string, b: string) { + return a < b ? `${a}|${b}` : `${b}|${a}`; + } + + update(dt: number) { + this.clock += dt; + for (const c of this.roamers.values()) { + tickNeeds(c.needs, c.traits, dt); + this.step(c, dt); + // A task that just finished is something it can learn from (the mind writes it down), and + // good news for whoever's close enough to hear it. + const w = this.worker(c.id); + if (c.lastStatus === 'working' && w?.status === 'done') { + this.mind.learn(c, w.prompt ?? w.title ?? ''); + this.ambient.cheerFor(c); + } + c.lastStatus = w?.status; + if (c.bubble && this.clock >= c.bubble.until) this.ambient.dropBubble(c); + } + // Who's still got nothing to do decides afresh every tick (a status can change mid-walk). + for (const c of this.roamers.values()) { + if (this.free(c)) { + if (c.state === 'seated') { + c.timer -= dt; + if (c.timer <= 0) this.ambient.goOut(c); + } else if (c.state === 'linger') { + c.timer -= dt; + if (c.timer <= 0) this.goHome(c); + } + } else if (c.state !== 'seated') this.goHome(c); + } + separate(this.roamers.values(), (a, b) => this.ambient.passBy(a, b)); + // Relationships cool without contact: affinity drifts back toward neutral, so the office's + // social map evolves instead of only ever warming up. + for (const [k, v] of this.affinity) { + const d = v * Math.exp(-dt / 900); + if (Math.abs(d) < 0.03) { + this.affinity.delete(k); + this.mind.saveAffinity({ [k]: 0 }); // cooled off: forgotten here and on the server + } else this.affinity.set(k, d); + } + // Now and then, two workers out about stop for a chat. + this.chatIn -= dt; + if (this.chatIn <= 0) { + this.chatIn = CHAT_EVERY + Math.random() * CHAT_EVERY; + this.startChat(); + } + } + + /** + * Two workers out and about, close enough to be worth it, walk to a point between them and trade + * lines. Who stops for whom depends on how sociable the first is, what it needs, and how it + * feels about each of them; each speaks in its own voice and persona. + */ + private startChat() { + const out = [...this.roamers.values()].filter((c) => c.state === 'linger' && this.free(c) && !c.chatWith); + if (out.length < 2) return; + // The most company-starved, sociable worker out there looks for someone first. + const a = out.reduce((best, c) => (c.traits.sociability * (0.5 + c.needs.social) > best.traits.sociability * (0.5 + best.needs.social) ? c : best)); + if (a.rng() > 0.3 + 0.7 * a.traits.sociability) return; + const pa = a.model.root.position; + // How far it'll call out depends on who it feels about them: friends from across the room, + // rivals only when they're already close. + const near = out.filter((c) => c !== a && c.model.root.position.distanceTo(pa) < CHAT_REACH * (1 + 0.8 * (this.affinity.get(this.key(a.id, c.id)) ?? 0))); + if (!near.length) return; + // Friends are likelier to be picked, rivals least (though not never: it's an office). + const weights = near.map((c) => 0.4 + c.traits.sociability + (this.affinity.get(this.key(a.id, c.id)) ?? 0)); + let x = a.rng() * weights.reduce((s, w) => s + w, 0); + let b = near[near.length - 1]; + for (let i = 0; i < near.length; i++) if ((x -= Math.max(0.05, weights[i])) <= 0) { b = near[i]; break; } + const pb = b.model.root.position; + const mx = (pa.x + pb.x) / 2; + const mz = (pa.z + pb.z) / 2; + const d = Math.hypot(pb.x - pa.x, pb.z - pa.z) || 1; + const ux = (pb.x - pa.x) / d; + const uz = (pb.z - pa.z) / d; + const nav = this.nav(); + for (const [c, sgn] of [[a, -1], [b, 1]] as const) { + // A little sideways jitter each, so the nav grid doesn't collapse both onto the same cell. + const [sx, sz] = nav.nearestWalkable([ + mx + ux * 0.9 * sgn - uz * (c.rng() - 0.5) * 0.8, + mz + uz * 0.9 * sgn + ux * (c.rng() - 0.5) * 0.8, + ]); + c.spot = { x: sx, z: sz, rotY: 0 }; + c.chatWith = sgn < 0 ? b.id : a.id; + this.walkTo(c, [sx, sz]); + } + // The walk over takes a few seconds: ask the office mind for their script now, so it's + // usually ready by the time they face each other. + const turns = 4 + Math.round(((a.traits.sociability + b.traits.sociability) / 2) * 4); + this.mind.ask(a, b, turns); + } + + /** Back to its desk: from the spot it's at, or from mid-chat, mid-hop, anywhere. */ + private goHome(c: Roamer) { + if (c.spot?.kind) c.lastKind = c.spot.kind; + c.spot = null; + c.chatWith = undefined; + delete c.chat; + c.timer = this.leaveWait(c); + this.walkTo(c, null); + } + + /** Sets off for `target` (a spot), or for its seat (null), from wherever it is. */ + private walkTo(c: Roamer, target: Pt | null): void { + c.model.stopDancing(); + if (c.state === 'seated') { + if (!target) return; + this.detach(c); + c.way = this.nav().wayFrom(c.desk.def, target); + c.from.copy(c.model.root.position); + c.state = 'down'; + c.hop = 0; + c.next = 0; + return; + } + const p = c.model.root.position; + const here: Pt = [p.x, p.z]; + // Mid-hop onto its seat and wanted back out (it got idle again): finishes the hop, then sets off again. + if (c.state === 'up' && target) { + c.state = 'seated'; + this.seat(c); + return this.walkTo(c, target); + } + // Still getting down: from where it lands, it heads the new way. + if (c.state === 'down') { + const land = c.way[0]; + c.way = [land, ...(target ? this.nav().route(land, target) : this.nav().wayTo(land, c.desk.def)).slice(1)]; + return; + } + // Mid-hop up off its seat and wanted home: it finishes the arc and sits back down by itself. + if (c.state === 'up' && !target) return; + c.way = target ? this.nav().route(here, target) : this.nav().wayTo(here, c.desk.def); + c.next = 1; + c.state = 'walk'; + } + + /** Out of its seat, into the room, keeping where it is in the world. */ + private detach(c: Roamer) { + const root = c.model.root; + const pos = root.getWorldPosition(this.tmp).clone(); + const scale = root.getWorldScale(new THREE.Vector3()).x; + const turn = root.getWorldQuaternion(new THREE.Quaternion()); + this.parent.add(root); + root.position.copy(pos); + c.heading = new THREE.Euler().setFromQuaternion(turn, 'YXZ').y; + root.rotation.set(0, c.heading, 0); + root.scale.setScalar(scale); + } + + /** Back in its seat: from here on it's where every worker sits. */ + private seat(c: Roamer) { + const root = c.model.root; + c.state = 'seated'; + c.model.walking = false; + c.model.gait = 1; + this.ambient.dropBubble(c); + c.desk.seatAnchor.add(root); + root.position.set(0, 0, 0); + root.rotation.set(0, 0, 0); + root.scale.setScalar(1); + } + + private step(c: Roamer, dt: number) { + const root = c.model.root; + const pos = root.position; + if (c.state === 'seated') return; + if (c.state === 'down') { + // Down off the seat in a little arc, turning to face the way it's going. + c.hop = Math.min(1, c.hop + dt / HOP); + const [x0, z0] = c.way[0]; + const floor = this.ground(x0, z0, c.from.y + 0.5) - FEET; + pos.set(THREE.MathUtils.lerp(c.from.x, x0, c.hop), THREE.MathUtils.lerp(c.from.y, floor, c.hop) + Math.sin(c.hop * Math.PI) * 0.3, THREE.MathUtils.lerp(c.from.z, z0, c.hop)); + const [x1, z1] = c.way[1] ?? c.way[0]; + if (x1 !== x0 || z1 !== z0) c.heading = Math.atan2(x1 - x0, z1 - z0); + root.rotation.set(0, root.rotation.y + wrap(c.heading - root.rotation.y) * smooth(HOP_TURN, dt), 0); + if (c.hop >= 1) { + c.state = 'walk'; + c.next = 1; + } + return; + } + if (c.state === 'up') { + // Up onto the seat in a little arc, turning to face the desk. + c.hop = Math.min(1, c.hop + dt / HOP); + const seat = c.desk.seatAnchor.getWorldPosition(this.tmp); + pos.set(THREE.MathUtils.lerp(c.from.x, seat.x, c.hop), THREE.MathUtils.lerp(c.from.y, seat.y, c.hop) + Math.sin(c.hop * Math.PI) * 0.3, THREE.MathUtils.lerp(c.from.z, seat.z, c.hop)); + root.rotation.set(0, root.rotation.y + wrap(c.desk.def.rotY + Math.PI - root.rotation.y) * smooth(HOP_TURN, dt), 0); + if (c.hop >= 1) this.seat(c); + return; + } + if (c.state === 'walk') { + // Which way the next corner sends it. + let target = root.rotation.y; + if (c.next < c.way.length) { + const [tx, tz] = c.way[c.next]; + const dx = tx - pos.x; + const dz = tz - pos.z; + if (dx * dx + dz * dz > 1e-8) target = Math.atan2(dx, dz); + } + // It slows a touch while it's still turning toward its heading, so it doesn't skate round the corners. + const speedFactor = 0.6 + 0.4 * Math.max(0, Math.cos(wrap(target - root.rotation.y))); + // Its own pace: brisk ones stride, mellow ones amble. + const pace = c.model.pace * c.traits.pace; + let move = WALK * pace * speedFactor * dt; + while (move > 0 && c.next < c.way.length) { + const [x, z] = c.way[c.next]; + const dx = x - pos.x; + const dz = z - pos.z; + const d = Math.hypot(dx, dz); + if (d <= move) { + pos.x = x; + pos.z = z; + move -= d; + c.next++; + } else { + pos.x += (dx / d) * move; + pos.z += (dz / d) * move; + move = 0; + } + } + // Turn toward where it's going at a walk's pace, and keep its feet going as fast as it really moves. + const turn = wrap(target - root.rotation.y); + root.rotation.y += THREE.MathUtils.clamp(turn, -TURN_RATE * dt, TURN_RATE * dt); + c.heading = root.rotation.y; + c.model.walking = c.next < c.way.length; + c.model.gait = 1.15 * speedFactor * (0.9 + 0.2 * c.traits.energy) * c.traits.pace; + if (c.model.walking) { + c.stepIn -= dt; + if (c.stepIn <= 0) { + c.stepIn += Math.PI / (9 * pace * speedFactor); + this.footstep(pos.x, pos.y, pos.z); + } + } else if (c.spot) { + // At its spot: a chat rendezvous if it's still on, a hang-out spot otherwise. + const meet = c.chatWith; + c.chatWith = undefined; + const p = meet && this.roamers.get(meet); + if (p && (p.chatWith === c.id || p.chat?.partner === c.id)) { + c.state = 'chat'; + // Both sides work the turn count out the same way, so they agree on when it ends. + const turns = 4 + Math.round(((c.traits.sociability + p.traits.sociability) / 2) * 4); + c.chat = { partner: meet, start: this.clock, seen: -1, turns, wait: 0, said: [] }; + c.timer = 0; + c.stepIn = 2; + } else { + c.state = 'linger'; + c.timer = this.stayFor(c, c.spot.kind); + c.stepIn = 2 + c.rng() * 4; + this.ambient.arrive(c); + } + } else { + // Back beside its seat: up onto it. + c.state = 'up'; + c.hop = 0; + c.from.copy(pos); + } + } else if (c.state === 'linger') { + // Lingering: standing about at its spot, now and then a hop for joy, and its own little asides. + c.stepIn -= dt; + if (c.stepIn <= 0) { + const kind = c.spot?.kind; + const lively = kind === 'arcade' || kind === 'dj' || kind === 'jukebox' ? 0.7 : 0.3; + c.stepIn += 3 + c.rng() * 5; + if (c.rng() < lively * (0.5 + c.traits.energy)) c.model.cheer(1.4); + } + c.asideIn -= dt; + if (c.asideIn <= 0 && !c.bubble) { + c.asideIn = 6 + c.rng() * 10; + // You walking past is worth a hello now and then, in its own voice. + const you = this.playerAt?.(); + const near = you && Math.hypot(pos.x - you.x, pos.z - you.z) < 3.2; + const text = near && c.rng() < 0.3 + 0.4 * c.traits.sociability + ? styled(c.traits, pick(c.rng, HELLO), c.rng) + : c.rng() < 0.5 ? mutter(c.traits, c.rng) : c.spot?.kind ? spotLine(c.traits, c.spot.kind, c.rng) : null; + if (text) this.ambient.say(c, text); + } + } else if (c.state === 'chat') { + // Stopped for a chat: facing who it's with, trading lines on shared turns. + const chat = c.chat!; + const p = this.roamers.get(chat.partner); + const committed = !!p && (p.chatWith === c.id || p.chat?.partner === c.id); + if (!committed) { + this.goHome(c); + return; + } + const pp = p!.model.root.position; + c.heading = Math.atan2(pp.x - pos.x, pp.z - pos.z); + root.rotation.y += wrap(c.heading - root.rotation.y) * smooth(FACE_RATE, dt); + if (p!.state !== 'chat') { + // Still walking over: wait a spell for it, then give up and head home. + chat.wait += dt; + if (chat.wait > 8) this.goHome(c); + } else { + chat.wait = 0; + // Both sides count the same turns from the later of their two start times, so they never talk over each other. + const start = Math.max(chat.start, p!.chat!.start); + const t = Math.floor((this.clock - start) / LINE_SECS); + if (t > chat.seen) { + chat.seen = t; + const first = c.id < chat.partner; + if (t >= chat.turns) { + this.finishChat(c, p!, first); + this.goHome(c); + return; + } + if ((t % 2 === 0) === first) { + const rel = relation(this.affinity.get(this.key(c.id, chat.partner)) ?? 0); + // The office mind's script when it came back in time, its own procedural voice otherwise. + const script = this.mind.script(c.id, chat.partner); + const mine = script ? (script.aId === c.id ? script.a : script.b) : null; + const scripted = mine?.[Math.floor(t / 2)]; + const line = scripted ?? chatLine(c.traits, rel, t, t >= chat.turns - 2, c.rng, this.ambient.personaLines(c)); + this.ambient.say(c, line); + chat.said.push(line); + // A lively talker gestures. + if (c.rng() < 0.15 + 0.25 * c.traits.energy) c.model.cheer(0.8); + } + } + } + } + // Up and down steps as it goes, and facing its spot once it's there. + const g = this.ground(pos.x, pos.z, pos.y + FEET + 0.35) - FEET; + pos.y += (g - pos.y) * smooth(GROUND_RATE, dt); + if (c.state === 'linger' && c.spot) root.rotation.y += wrap(c.spot.rotY - root.rotation.y) * smooth(FACE_RATE, dt); + // On furniture it sits: up onto the seat's height and leaning back, easing in and out of it. + const wantSit = c.state === 'linger' && c.spot?.seat !== undefined ? 1 : 0; + c.sit += (wantSit - c.sit) * smooth(5, dt); + if (c.sit > 0.01) { + pos.y += c.sit * (c.spot!.seat! - 0.15); + root.rotation.x = -c.sit * 0.32; + } + } + + /** A chat's over: both feel less lonely, the mind notes what to remember, and how it went shifts how they feel about each other. */ + private finishChat(c: Roamer, p: Roamer, first: boolean) { + c.needs.social = Math.max(0, c.needs.social - 0.6); + // Tell the mind what this one said, so it remembers the other worker next time (fire-and-forget). + if (c.chat?.said.length) this.mind.reflect(c, p, c.chat.said); + if (!first) return; + const k = this.key(c.id, p.id); + const next = Math.max(-1, Math.min(1, (this.affinity.get(k) ?? 0) + chatOutcome(c.traits, p.traits, c.rng))); + this.affinity.set(k, next); + this.mind.saveAffinity({ [k]: next }); + // Both are done with this pair's script; the next chat asks for a fresh one. + this.mind.forget(c.id, p.id); + } +} diff --git a/src/client/ui/workers-panel.ts b/src/client/ui/workers-panel.ts index 2bd3fb817..39844d7f6 100644 --- a/src/client/ui/workers-panel.ts +++ b/src/client/ui/workers-panel.ts @@ -4,6 +4,7 @@ import { needyFirst } from '../nextup'; import { store } from '../state'; import { DESK_BY_ID } from '../../shared/layout'; +import { personaFor } from '../../shared/personas'; import { $, h, STATUS_LABEL, timeAgo } from './dom'; import { usageLabel, usageTitle } from './usage'; import { providerLabel, providerUsageState, providerWaitingLabel, resolvedProvider, modelBadge } from './provider'; @@ -20,7 +21,8 @@ export function renderWorkers(onOpen: (id: string) => void) { const waiting = usageState === 'waiting' ? providerWaitingLabel(providerKind, store.project) : ''; const usageNote = usageState === 'untracked' ? ' · usage untracked' : waiting ? ` · ${waiting}` : ''; const badge = w.kind === 'agent' ? modelBadge(w.provider, w.model, w.effort, w.usage?.model) : undefined; - const sub = [provider && `⚙️ ${provider}${badge ? ` · ${badge}` : ''}${usageNote}`, w.worktree && `🌿 ${w.worktree.branch}`, w.repos?.length && `🗂️ ${w.repos.length + 1} repos`, w.pr && `🔀 PR #${w.pr.number}`, w.activity || w.title || w.prompt].filter(Boolean).join(' · '); + const persona = w.kind === 'agent' && !DESK_BY_ID.get(w.deskId)?.station && store.project ? personaFor(store.project.dir, w.deskId) : undefined; + const sub = [persona && `🧬 ${persona.name}`, provider && `⚙️ ${provider}${badge ? ` · ${badge}` : ''}${usageNote}`, w.worktree && `🌿 ${w.worktree.branch}`, w.repos?.length && `🗂️ ${w.repos.length + 1} repos`, w.pr && `🔀 PR #${w.pr.number}`, w.activity || w.title || w.prompt].filter(Boolean).join(' · '); // What it's stopped on, and since when, on a line of its own under its name. const ask = asking ? h('span.ask', {}, `🙋 ${w.activity ?? 'Waiting on an answer'}${w.waitingSince ? ` · ${timeAgo(w.waitingSince)}` : ''}`) : null; ul.append( diff --git a/src/server/http/routes/index.ts b/src/server/http/routes/index.ts index 982acdd1b..018b98d8d 100644 --- a/src/server/http/routes/index.ts +++ b/src/server/http/routes/index.ts @@ -6,6 +6,7 @@ import { agentRoutes } from './agents.js'; import { authRoutes } from './auth.js'; import { fileRoutes } from './files.js'; import { githubRoutes } from './github.js'; +import { mindRoutes } from './mind.js'; import { pageRoutes } from './pages.js'; import { searchRoutes } from './search.js'; import { serviceRoutes } from './services.js'; @@ -36,6 +37,7 @@ export const routes: readonly Route[] = [ searchRoutes.search, serviceRoutes.forwards, githubRoutes.github, + mindRoutes.mind, pageRoutes.office, pageRoutes.lite, pageRoutes.bundle, diff --git a/src/server/http/routes/mind.ts b/src/server/http/routes/mind.ts new file mode 100644 index 000000000..ef27bb0a8 --- /dev/null +++ b/src/server/http/routes/mind.ts @@ -0,0 +1,71 @@ +// The local mind's HTTP face: the wanderers ask it for a hallway chat's script, and report back +// what to remember (see ../../mind.ts). +import { Mind, type MindActor } from '../../mind.js'; +import { readBody, send } from '../util.js'; +import type { Route } from '../router.js'; + +let mind: Mind | undefined; +const getMind = (ctx: { cfg: { dataDir: string } }) => (mind ??= new Mind(ctx.cfg.dataDir)); + +const actor = (v: unknown): MindActor | null => { + const a = v as Partial | undefined; + if (!a || typeof a.id !== 'string' || typeof a.tone !== 'string') return null; + return { + id: a.id.slice(0, 80), + persona: typeof a.persona === 'string' ? a.persona.slice(0, 80) : undefined, + tone: a.tone.slice(0, 24), + quirk: typeof a.quirk === 'string' ? a.quirk.slice(0, 24) : '', + sociability: typeof a.sociability === 'number' ? a.sociability : 0.5, + energy: typeof a.energy === 'number' ? a.energy : 0.5, + mischief: typeof a.mischief === 'number' ? a.mischief : 0.5, + task: typeof a.task === 'string' ? a.task.slice(0, 200) : undefined, + memory: Array.isArray(a.memory) ? a.memory.filter((x): x is string => typeof x === 'string').slice(-8).map((x) => x.slice(0, 140)) : [], + }; +}; + +export const mindRoutes = { + /** POST /api/mind — { floor, op: 'chat', a, b, turns } or { floor, op: 'reflect', a, b, saidA, saidB } */ + mind: { + method: 'POST', + path: '/api/mind', + auth: 'session', + async handle(ctx, { res, req }) { + let body: { floor?: unknown; op?: unknown; a?: unknown; b?: unknown; turns?: unknown; saidA?: unknown; saidB?: unknown; task?: unknown; pairs?: unknown }; + try { + body = JSON.parse(await readBody(req, 64 * 1024)); + } catch { + return send(res, 400, { error: 'Bad request' }); + } + const floor = typeof body.floor === 'string' ? body.floor.slice(0, 200) : 'office'; + const a = actor(body.a); + const b = actor(body.b); + const m = getMind(ctx); + if (body.op === 'affinity') { + // The floor's remembered relationships: read them, or merge an update and get them back. + if (body.pairs && typeof body.pairs === 'object' && !Array.isArray(body.pairs)) { + const clean: Record = {}; + for (const [k, v] of Object.entries(body.pairs as Record).slice(0, 300)) { + if (typeof k === 'string' && k.length <= 200 && typeof v === 'number' && Number.isFinite(v)) clean[k] = v; + } + m.setAffinity(floor, clean); + } + return send(res, 200, { affinity: m.affinity(floor) }); + } + if (body.op === 'learn') { + // A worker finished a task: it writes down what it took away (fire-and-forget). + if (a && typeof body.task === 'string') m.learn(floor, a, body.task.slice(0, 300)); + return send(res, 202, { ok: true }); + } + if (!a || !b) return send(res, 400, { error: 'Bad actors' }); + if (body.op === 'reflect') { + const lines = (v: unknown) => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string').slice(0, 12).map((x) => x.slice(0, 120)) : []); + m.reflect(floor, a, b, lines(body.saidA), lines(body.saidB)); + return send(res, 202, { ok: true }); + } + const turns = typeof body.turns === 'number' ? Math.max(2, Math.min(8, Math.floor(body.turns))) : 4; + const script = await m.chat(floor, { ...a, memory: m.remembered(floor, a.id) }, { ...b, memory: m.remembered(floor, b.id) }, turns); + if (!script) return send(res, 503, { error: 'The office mind is away (is Ollama running?)' }); + return send(res, 200, script); + }, + }, +} satisfies Record; diff --git a/src/server/mind.ts b/src/server/mind.ts new file mode 100644 index 000000000..c25ca0785 --- /dev/null +++ b/src/server/mind.ts @@ -0,0 +1,434 @@ +// The office's small local mind: an Ollama model that gives idle workers a voice of their own in +// hallway chats and keeps a note of who they talked to. It only ever writes a line or two — it +// never touches code, tools or the repo, and the procedural chatter in features/workers/wander.ts +// covers for it whenever Ollama isn't there. +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +const OLLAMA = (process.env.OFFICE_MIND_URL ?? 'http://127.0.0.1:11434').replace(/\/$/, ''); +const MODEL = process.env.OFFICE_MIND_MODEL ?? 'llama3.2:3b'; +const EMBED = process.env.OFFICE_MIND_EMBED ?? 'nomic-embed-text'; +const GENERATE_MS = 90_000; + +/** The trait values as the model should read them (the traits themselves stay English). */ +const TONE_ES: Record = { dry: 'seca', warm: 'cálida', hyper: 'hiperactiva', gloomy: 'sombría', formal: 'formal', chaotic: 'caótica' }; +const QUIRK_ES: Record = { hummer: 'tararea', muttering: 'murmura', stretcher: 'se estira', cheerleader: 'porrista', sigher: 'se suspira', none: 'ninguna' }; +const TAGS_MS = 800; + +/** One worker's side of a chat, as the client describes it (see features/workers/wander.ts). */ +export interface MindActor { + id: string; + /** Its persona's name, when the desk has one. */ + persona?: string; + tone: string; + quirk: string; + sociability: number; + energy: number; + mischief: number; + /** The task it last worked on, if any — what its memory gets retrieved against. */ + task?: string; + /** What it remembers from earlier chats (the mind's own notes, newest last). */ + memory: string[]; +} + +/** A memory note, when it was written, and — when the embed model is around — its vector for relevance retrieval. */ +interface Note { + text: string; + at?: number; + vec?: number[]; +} + +interface Memory { + notes: Note[]; +} + +/** What the office as a whole has learned, per floor: things worth everyone knowing. */ +type FloorNotes = Record; + +/** A worker's memory across chats, kept on disk so it survives restarts. */ +export class Mind { + private memories = new Map(); + private floors: FloorNotes = {}; + /** How each pair of workers feels about the other, per floor — kept so relationships survive restarts. */ + private social: Record> = {}; + private file: string; + private up = false; + private checked = 0; + private dirty = false; + + constructor(dataDir: string) { + this.file = path.join(dataDir, 'minds.json'); + try { + const saved = JSON.parse(readFileSync(this.file, 'utf8')) as { + workers?: Record; + floors?: FloorNotes; + social?: Record>; + } | Record; + // The file used to be just the workers map; it grew a floors section for shared knowledge. + const workers = 'workers' in saved ? saved.workers ?? {} : (saved as Record); + const toNote = (n: unknown): Note | null => { + if (typeof n === 'string') return { text: n, at: Date.now() }; + const note = n as Note | undefined; + return note && typeof note.text === 'string' ? { ...note, at: note.at ?? Date.now() } : null; + }; + for (const [k, v] of Object.entries(workers)) { + // Notes were plain strings once; they're {text, at, vec?} now. + const notes = (Array.isArray(v?.notes) ? v.notes : []).map(toNote).filter((n: Note | null): n is Note => !!n).slice(-12); + this.memories.set(k, { notes }); + } + if ('floors' in saved && saved.floors && typeof saved.floors === 'object') { + this.floors = Object.fromEntries( + Object.entries(saved.floors as Record).map(([f, list]) => [ + f, + (Array.isArray(list) ? list : []).map(toNote).filter((n: Note | null): n is Note => !!n).slice(-15), + ]), + ); + } + if ('social' in saved && saved.social && typeof saved.social === 'object') { + for (const [f, pairs] of Object.entries(saved.social)) { + if (!pairs || typeof pairs !== 'object') continue; + const clean: Record = {}; + for (const [k, v] of Object.entries(pairs)) if (typeof v === 'number' && Number.isFinite(v)) clean[k] = Math.max(-1, Math.min(1, v)); + if (Object.keys(clean).length) this.social[f] = clean; + } + } + } catch { + // No memories yet (or a corrupt file): start clean. + } + // Load the model into memory soon after the office starts, so the first chat isn't a cold start. + setTimeout(() => { + void fetch(`${OLLAMA}/api/generate`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model: MODEL, prompt: '', stream: false, keep_alive: '30m', options: { num_predict: 1 } }), + }).catch(() => {}); + }, 3000); + } + + private key(floor: string, id: string) { + return `${floor}:${id}`; + } + + private notes(floor: string, id: string): string[] { + return (this.memories.get(this.key(floor, id))?.notes ?? []).map((n) => n.text); + } + + /** Whether Ollama answers right now (checked at most every few seconds). */ + private async available(): Promise { + if (this.up && Date.now() - this.checked < 15_000) return true; + this.checked = Date.now(); + try { + const res = await fetch(`${OLLAMA}/api/tags`, { signal: AbortSignal.timeout(TAGS_MS) }); + this.up = res.ok; + if (this.up) { + const tags = (await res.json()) as { models?: { name: string }[] }; + this.up = (tags.models ?? []).some((m) => m.name === MODEL || m.name.startsWith(`${MODEL}:`)); + } + } catch { + this.up = false; + } + return this.up; + } + + /** One completion from the local model, or null when it's not up or takes too long. */ + private async ask(prompt: string): Promise { + if (!(await this.available())) return null; + try { + const res = await fetch(`${OLLAMA}/api/generate`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + // keep_alive: chats are sporadic — leave the model warm so the next one isn't a cold start. + body: JSON.stringify({ model: MODEL, prompt, stream: false, keep_alive: '30m', options: { temperature: 0.9, num_predict: 380, num_ctx: 2048 } }), + signal: AbortSignal.timeout(GENERATE_MS), + }); + if (!res.ok) { + this.up = false; + return null; + } + const out = (await res.json()) as { response?: string }; + return out.response?.trim() || null; + } catch { + return null; + } + } + + /** An embedding for `text`, or null when the embed model isn't there (memory then falls back to recency). */ + private async embed(text: string): Promise { + if (this.embedDead) return null; + try { + const res = await fetch(`${OLLAMA}/api/embed`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model: EMBED, input: text }), + signal: AbortSignal.timeout(10_000), + }); + if (!res.ok) { + this.embedDead = true; + return null; + } + const out = (await res.json()) as { embeddings?: number[][] }; + return out.embeddings?.[0] ?? null; + } catch { + this.embedDead = true; + return null; + } + } + + private embedDead = false; + + private cosine(a: number[], b: number[]): number { + let dot = 0; + let na = 0; + let nb = 0; + for (let i = 0; i < a.length && i < b.length; i++) { + dot += a[i] * b[i]; + na += a[i] * a[i]; + nb += b[i] * b[i]; + } + return na && nb ? dot / Math.sqrt(na * nb) : 0; + } + + /** Old notes fade: a week-old memory counts half as much as a fresh one when picking what to recall. */ + private ageWeight(at?: number): number { + if (!at) return 0.5; + const days = (Date.now() - at) / 86_400_000; + return Math.pow(0.5, Math.max(0, days) / 7); + } + + /** An embedding lands on the note whenever it lands; nothing waits on it. */ + private vectorize(note: Note) { + void this.embed(note.text).then((vec) => { + if (vec) { + note.vec = vec; + this.dirty = true; + this.flush(); + } + }); + } + + /** + * The notes worth bringing into this chat: the ones most similar to what the two are about + * (the partner's persona, its last task), weighted down as they age, plus the newest one for + * continuity. Without the embed model it's just the latest few. + */ + private async recall(floor: string, a: MindActor, b: MindActor): Promise<{ a: string[]; b: string[] }> { + const pick = async (actor: MindActor, other: MindActor): Promise => { + const items = this.memories.get(this.key(floor, actor.id))?.notes ?? []; + if (!items.length) return []; + const vecs = items.filter((n) => n.vec).length; + if (!vecs) return items.slice(-4).map((n) => n.text); + const q = await this.embed(`${other.persona ?? ''} ${other.task ?? ''} ${actor.persona ?? ''}`); + if (!q) return items.slice(-4).map((n) => n.text); + const scored = items + .map((n, i) => ({ n, s: n.vec ? this.cosine(n.vec, q) * this.ageWeight(n.at) : -1, i })) + .sort((x, y) => y.s - x.s || y.i - x.i); + const top = scored.slice(0, 3).map((x) => x.n.text); + const newest = items[items.length - 1].text; + return [...new Set([...top, newest])].slice(0, 4); + }; + const [ma, mb] = await Promise.all([pick(a, b), pick(b, a)]); + return { a: ma, b: mb }; + } + + /** + * The floor's shared notes worth surfacing in this chat: the ones most similar to what the pair + * is about, aged the same way. Falls back to the latest few without the embed model. + */ + private async shared(floor: string, a: MindActor, b: MindActor): Promise { + const items = this.floors[floor] ?? []; + if (!items.length) return []; + if (!items.some((n) => n.vec)) return items.slice(-6).map((n) => n.text); + const q = await this.embed(`${a.persona ?? ''} ${a.task ?? ''} ${b.persona ?? ''} ${b.task ?? ''}`); + if (!q) return items.slice(-6).map((n) => n.text); + const scored = items + .map((n, i) => ({ n, s: n.vec ? this.cosine(n.vec, q) * this.ageWeight(n.at) : -1, i })) + .sort((x, y) => y.s - x.s || y.i - x.i); + const top = scored.slice(0, 4).map((x) => x.n.text); + const newest = items[items.length - 1].text; + return [...new Set([...top, newest])].slice(0, 6); + } + + private actorPrompt(a: MindActor, who: string, memory?: string[]): string { + const mem = memory?.length ? `\n recuerda: ${memory.join('; ')}` : a.memory.length ? `\n recuerda: ${a.memory.slice(-4).join('; ')}` : ''; + return ` ${who}: persona "${a.persona ?? 'ninguna'}", voz ${TONE_ES[a.tone] ?? a.tone}, manía ${QUIRK_ES[a.quirk] ?? a.quirk ?? 'ninguna'}, sociabilidad ${a.sociability.toFixed(1)}, energía ${a.energy.toFixed(1)}, travesura ${a.mischief.toFixed(1)}${mem}`; + } + + /** + * A hallway chat between two workers: `turns` lines each, alternating, as two JSON arrays. + * Null when the mind is away — the caller falls back to its procedural lines. + */ + async chat(floor: string, a: MindActor, b: MindActor, turns: number): Promise<{ a: string[]; b: string[] } | null> { + if (!(await this.available())) return null; + // A local 3B on CPU takes ~30s for a full script: four lines each is the sweet spot — the + // client's procedural lines cover whichever turns the script doesn't reach in time. + const n = Math.min(turns, 4); + const mem = await this.recall(floor, a, b); + const shared = await this.shared(floor, a, b); + const prompt = [ + 'Dos compañeros de IA charlan en la cocina de la oficina. Escribe su conversación.', + this.actorPrompt(a, 'A', mem.a), + this.actorPrompt(b, 'B', mem.b), + // What the office has learned lately: they can pick it up, the way coworkers do. + ...(shared.length ? ['Lo que la oficina sabe últimamente:', ...shared.map((s) => ` - ${s}`)] : []), + `Reglas: exactamente ${n} líneas cortas cada uno, máximo 10 palabras, en personaje, sobre plática de oficina (código, bugs, deadlines, café). En español. Sin nombres, sin comillas, sin narración.`, + 'Format: one line per utterance, alternating, each starting with "A:" or "B:". Nothing else.', + ].join('\n'); + const text = await this.ask(prompt); + const sa: string[] = []; + const sb: string[] = []; + for (const raw of (text ?? '').split('\n')) { + const m = /^\s*([AB])\s*:\s*(.+?)\s*$/.exec(raw); + if (!m) continue; + (m[1] === 'A' ? sa : sb).push(m[2].replace(/^["']|["']$/g, '').slice(0, 90)); + } + if (!sa.length && !sb.length) return null; + return { a: sa.slice(0, n), b: sb.slice(0, n) }; + } + + /** + * After a chat, a note about what mattered — kept in its memory for next time, plus an + * "OFICINA:" line for anything the whole floor should know. Fire-and-forget; never blocks. + */ + reflect(floor: string, a: MindActor, b: MindActor, saidA: string[], saidB: string[]) { + void this.available().then(async (up) => { + if (!up) return; + const transcript = [...saidA.map((l) => `A: ${l}`), ...saidB.map((l) => `B: ${l}`)].join('\n'); + const prompt = [ + 'Un compañero de IA acaba de terminar una charla en el pasillo. Escribe dos líneas en español:', + '1. En UNA frase corta (máximo 15 palabras): qué debe recordar — cómo se siente del otro trabajador, o un tema para la próxima vez.', + '2. Si la charla reveló algo útil para TODA la oficina (un bug, una convención, un aviso, un cumpleaños), otra frase empezando con "OFICINA:". Si no hay nada, omítela.', + 'Sin preámbulos, sin comillas.', + this.actorPrompt(a, 'Él'), + `El otro: persona "${b.persona ?? 'ninguna'}", voz ${TONE_ES[b.tone] ?? b.tone}.`, + `La charla:\n${transcript}`, + ].join('\n'); + const out = await this.ask(prompt); + if (!out) return; + for (const raw of out.split('\n')) { + const line = raw.replace(/^[\s\-*\d.)]+/, '').replace(/^["']|["'.\s]+$/g, '').trim().slice(0, 140); + if (!line) continue; + if (/^OFICINA:/i.test(line)) { + const fact = line.replace(/^OFICINA:\s*/i, ''); + if (fact) this.share(floor, fact); + continue; + } + this.remember(floor, a.id, line); + } + this.maybeConsolidate(floor, a.id); + }); + } + + /** + * A worker finished a task: one line about what it learned or did worth remembering — personal + * memory, and shared with the floor when it's the kind of thing everyone should know. + */ + learn(floor: string, a: MindActor, task: string) { + void this.available().then(async (up) => { + if (!up || !task.trim()) return; + const prompt = [ + `Un compañero de IA acaba de terminar una tarea: "${task.slice(0, 200)}".`, + 'Escribe en español, sin preámbulos:', + '1. En UNA frase corta (máximo 15 palabras): qué aprendió o logró que valga recordar.', + '2. Si es algo útil para TODA la oficina (un bug encontrado, una convención, un aviso), otra frase empezando con "OFICINA:". Si no, omítela.', + ].join('\n'); + const out = await this.ask(prompt); + if (!out) return; + for (const raw of out.split('\n')) { + const line = raw.replace(/^[\s\-*\d.)]+/, '').replace(/^["']|["'.\s]+$/g, '').trim().slice(0, 140); + if (!line) continue; + if (/^OFICINA:/i.test(line)) { + const fact = line.replace(/^OFICINA:\s*/i, ''); + if (fact) this.share(floor, fact); + continue; + } + this.remember(floor, a.id, line); + } + this.maybeConsolidate(floor, a.id); + }); + } + + /** One durable note into a worker's memory, with its vector when the embed model is around. */ + private remember(floor: string, id: string, note: string) { + const k = this.key(floor, id); + const m = this.memories.get(k) ?? { notes: [] }; + const item: Note = { text: note, at: Date.now() }; + m.notes.push(item); + if (m.notes.length > 12) m.notes.splice(0, m.notes.length - 12); + this.memories.set(k, m); + this.dirty = true; + this.flush(); + this.vectorize(item); + } + + /** A fact the whole floor can pick up in conversation. */ + private share(floor: string, fact: string) { + const list = (this.floors[floor] ??= []); + if (list.some((n) => n.text === fact)) return; + const item: Note = { text: fact, at: Date.now() }; + list.push(item); + if (list.length > 15) list.splice(0, list.length - 15); + this.dirty = true; + this.flush(); + this.vectorize(item); + } + + /** + * Memory isn't just appended forever: once a worker has a pile of notes, the mind consolidates + * them into the few things that actually matter, so what it carries into the next chat is + * wisdom, not a log. + */ + private maybeConsolidate(floor: string, id: string) { + const m = this.memories.get(this.key(floor, id)); + if (!m || m.notes.length < 9) return; + const notes = m.notes.slice(); + void this.ask([ + 'Un compañero de IA ha vivido muchas charlas en la oficina. Destila sus notas en las 5 cosas que de verdad debe recordar (en español, una por línea, sin numerar ni viñetas):', + ...notes.map((n) => `- ${n.text}`), + ].join('\n')).then((out) => { + if (!out) return; + const distilled = out.split('\n').map((l) => l.replace(/^[\s\-*\d.)]+/, '').trim().slice(0, 140)).filter((l) => l.length > 8).slice(0, 5); + if (distilled.length) { + // The distilled notes lose their old vectors and timestamps; new ones arrive as they're embedded. + m.notes = distilled.map((text) => ({ text, at: Date.now() })); + for (const n of m.notes) this.vectorize(n); + this.dirty = true; + this.flush(); + } + }); + } + + /** Persists memories, at most once every few seconds. */ + flush() { + if (!this.dirty) return; + this.dirty = false; + try { + mkdirSync(path.dirname(this.file), { recursive: true }); + writeFileSync(this.file, JSON.stringify({ workers: Object.fromEntries(this.memories), floors: this.floors, social: this.social }, null, 2), { mode: 0o600 }); + } catch { + // A memory that can't be written is just forgotten on restart. + } + } + + /** The memory an actor carries into a prompt (for tests and debugging). */ + remembered(floor: string, id: string): string[] { + return this.notes(floor, id); + } + + /** The floor's pair affinities (-1 rivals … 1 friends), for a client to seed its own map with. */ + affinity(floor: string): Record { + return { ...(this.social[floor] ?? {}) }; + } + + /** Merge affinity updates from a client; values are clamped and near-zero pairs are forgotten. */ + setAffinity(floor: string, pairs: Record) { + const map = (this.social[floor] ??= {}); + for (const [k, v] of Object.entries(pairs)) { + const d = Math.max(-1, Math.min(1, v)); + if (Math.abs(d) < 0.03) delete map[k]; + else map[k] = d; + } + if (!Object.keys(map).length) delete this.social[floor]; + this.dirty = true; + this.flush(); + } +} diff --git a/src/server/opencode.ts b/src/server/opencode.ts index 1b3527fc1..269820da5 100644 --- a/src/server/opencode.ts +++ b/src/server/opencode.ts @@ -27,7 +27,7 @@ export type OpenCodeHookEvent = OpenCodeStatusEvent | OpenCodeUsageEvent; * Merge the per-process plugin, and the office's MCP server when there is one (`mcp`, see * openCodeMcp), into inline OpenCode config without touching user config files. */ -export function mergeOpenCodeConfigContent(existing: string | undefined, plugin: string, mcp?: Record): string { +export function mergeOpenCodeConfigContent(existing: string | undefined, plugin: string, mcp?: Record, instructions?: string[]): string { let config: Record = {}; if (existing) { try { @@ -35,6 +35,7 @@ export function mergeOpenCodeConfigContent(existing: string | undefined, plugin: if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('not an object'); config = { ...(parsed as Record) }; if (config.plugin !== undefined && !Array.isArray(config.plugin)) throw new Error('plugin is not an array'); + if (config.instructions !== undefined && !Array.isArray(config.instructions)) throw new Error('instructions is not an array'); } catch { throw new Error('OPENCODE_CONFIG_CONTENT must be valid JSON'); } @@ -46,6 +47,10 @@ export function mergeOpenCodeConfigContent(existing: string | undefined, plugin: const theirs = config.mcp && typeof config.mcp === 'object' && !Array.isArray(config.mcp) ? (config.mcp as Record) : {}; config.mcp = { ...theirs, ...mcp }; } + if (instructions?.length) { + const theirs = Array.isArray(config.instructions) ? (config.instructions as unknown[]).filter((x): x is string => typeof x === 'string') : []; + config.instructions = [...theirs, ...instructions.filter((x) => !theirs.includes(x))]; + } return JSON.stringify(config); } diff --git a/src/server/providers/claude.ts b/src/server/providers/claude.ts index 1eac357c3..f1350fcdd 100644 --- a/src/server/providers/claude.ts +++ b/src/server/providers/claude.ts @@ -3,6 +3,7 @@ import { writeFileSync } from 'node:fs'; import path from 'node:path'; import { FAILS_TO_DESPAIR, outputFailed, toolAction } from '../../shared/actions.js'; +import { personaBrief, personaFor } from '../../shared/personas.js'; import { MCP_READ_ONLY, writeClaudeMcpConfig } from '../office-workers.js'; import { QUEUE_AGENT_DISALLOWED_TOOLS } from '../stations.js'; import { answered, notified, wantsPermission } from '../workers/lifecycle.js'; @@ -26,6 +27,8 @@ interface ClaudeSetup { settings: string; /** Its --mcp-config: the office's MCP server. */ mcp?: string; + /** The project's checkout: the seed its desks' personas are dealt from. */ + dir: string; } /** Claude Code's --settings for every worker: each hook event posted to the office, with curl or else the office's own node. */ @@ -194,9 +197,10 @@ export const claude: ProviderAdapter = { id: 'claude', scrubEnv: ['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT', 'CLAUDE_CODE_SSE_PORT', 'CLAUDE_CODE_EXECPATH', 'CLAUDE_PID', 'CLAUDE_EFFORT'], scrubPrefixes: ['CLAUDE_CODE_SESSION', 'CLAUDE_CODE_CHILD', 'CLAUDE_CODE_MESSAGING'], - prepare: ({ dataDir, mcpScript }) => ({ + prepare: ({ dataDir, dir, mcpScript }) => ({ settings: writeHookSettings(dataDir), mcp: mcpScript ? writeClaudeMcpConfig(dataDir, mcpScript) : undefined, + dir, }), launch({ h: { info }, args, prompt, resumeSessionId, station, setup }) { args.unshift('--settings', setup.settings); @@ -208,6 +212,9 @@ export const claude: ProviderAdapter = { if (info.effort) args.push('--effort', info.effort); // The queue agent only ever adds to the queue: without these it can't touch the checkout's files. if (station === 'queue') args.push('--disallowedTools', ...QUEUE_AGENT_DISALLOWED_TOOLS); + // Its desk's persona, dealt from the floor's seed (see shared/personas.ts). Board kiosks keep their briefs. + const persona = station ? undefined : personaFor(setup.dir, info.deskId); + if (persona) args.push('--append-system-prompt', personaBrief(persona)); if (resumeSessionId) args.push('--resume', resumeSessionId); // `--` so a prompt like "- fix login" is never parsed as a CLI option. if (prompt) args.push('--', prompt); diff --git a/src/server/providers/codex.ts b/src/server/providers/codex.ts index 24ca1c1ff..90bc9bde7 100644 --- a/src/server/providers/codex.ts +++ b/src/server/providers/codex.ts @@ -2,6 +2,7 @@ // /hooks/codex; its usage is read from the root session's rollout (see codex-usage.ts). import { homedir } from 'node:os'; import path from 'node:path'; +import { withPersonaPrompt } from '../../shared/personas.js'; import { codexHookArgs, codexModelArgs, normalizeCodexHook, writeCodexHook } from '../codex.js'; import { CodexUsageReader } from '../codex-usage.js'; import { codexMcpArgs } from '../office-workers.js'; @@ -24,6 +25,8 @@ interface CodexState { interface CodexSetup { hook: string; mcpScript?: string; + /** The project's checkout: the seed its desks' personas are dealt from. */ + dir: string; } /** Where a Codex worker's sessions are logged, for reading its usage. */ @@ -90,8 +93,10 @@ export const codex: ProviderAdapter = { id: 'codex', scrubEnv: ['CODEX_THREAD_ID', 'CODEX_INTERNAL_ORIGINATOR_OVERRIDE'], createState: () => ({ usage: new CodexUsageReader(), tools: new Map(), pending: new Set() }), - prepare: ({ dataDir, mcpScript }) => ({ hook: writeCodexHook(dataDir), mcpScript }), - launch({ h, args, prompt, resumeSessionId, setup }) { + prepare: ({ dataDir, dir, mcpScript }) => ({ hook: writeCodexHook(dataDir), mcpScript, dir }), + launch({ h, args, prompt, resumeSessionId, station, setup }) { + // Its desk's persona, dealt from the floor's seed (see shared/personas.ts), ahead of its first prompt. + prompt = withPersonaPrompt(setup.dir, h.info.deskId, station, prompt); // Resumed too: Codex resumes on whatever its config says now, not on the model the session ran on. args = codexModelArgs(args, h.info.model, h.info.effort); args.push(...codexHookArgs(setup.hook), ...(setup.mcpScript ? codexMcpArgs(setup.mcpScript) : []), '--no-alt-screen'); diff --git a/src/server/providers/cursor.ts b/src/server/providers/cursor.ts index f1efc31d3..e45ad57d0 100644 --- a/src/server/providers/cursor.ts +++ b/src/server/providers/cursor.ts @@ -3,6 +3,7 @@ // and report on /hooks/cursor. It runs on the machine's own Cursor login. Its spend isn't metered by // the office, and nothing tells the office about a permission prompt: a worker waiting on one shows // as working until it's answered. +import { withPersonaPrompt } from '../../shared/personas.js'; import { addCursorHooks, cursorBlocked, normalizeCursorHook, removeCursorHooks, withoutCursorLaunchArgs, writeCursorHook } from '../cursor.js'; import { reduceLifecycle } from '../workers/lifecycle.js'; import type { ProviderAdapter } from './types.js'; @@ -10,14 +11,18 @@ import type { ProviderAdapter } from './types.js'; interface CursorSetup { /** The helper its hooks run. */ hook: string; + /** The project's checkout: the seed its desks' personas are dealt from. */ + dir: string; } export const cursor: ProviderAdapter = { id: 'cursor', // What Cursor sets in its own shells. CURSOR_API_KEY stays: a worker signs in with it when it's set. scrubEnv: ['CURSOR_AGENT', 'CURSOR_CLI', 'CURSOR_INVOKED_AS', 'CURSOR_CONVERSATION_ID', 'CURSOR_REQUEST_ID'], - prepare: ({ dataDir }) => ({ hook: writeCursorHook(dataDir) }), - launch({ h: { info }, args, prompt, resumeSessionId, cwd, setup }) { + prepare: ({ dataDir, dir }) => ({ hook: writeCursorHook(dataDir), dir }), + launch({ h: { info }, args, prompt, resumeSessionId, station, cwd, setup }) { + // Its desk's persona, dealt from the floor's seed (see shared/personas.ts), ahead of its first prompt. + prompt = withPersonaPrompt(setup.dir, info.deskId, station, prompt); args = withoutCursorLaunchArgs(args); addCursorHooks(cwd, setup.hook, info.id); // The office made this folder for it. Cursor's own permission prompts stay as they are. diff --git a/src/server/providers/grok.ts b/src/server/providers/grok.ts index fcd38a2ee..94fb21d34 100644 --- a/src/server/providers/grok.ts +++ b/src/server/providers/grok.ts @@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto'; import { existsSync } from 'node:fs'; import { homedir } from 'node:os'; import path from 'node:path'; +import { withPersonaPrompt } from '../../shared/personas.js'; import { normalizeGrokHook, withoutGrokLaunchArgs, writeGrokHome } from '../grok.js'; import { reduceLifecycle } from '../workers/lifecycle.js'; import type { ProviderAdapter } from './types.js'; @@ -13,6 +14,8 @@ interface GrokSetup { socket: string; /** The person's own Grok sign-in, when there is one. */ authPath?: string; + /** The project's checkout: the seed its desks' personas are dealt from. */ + dir: string; } export const grok: ProviderAdapter = { @@ -21,13 +24,15 @@ export const grok: ProviderAdapter = { 'GROK_SESSION_ID', 'GROK_AGENT_ID', 'GROK_HOOK_EVENT', 'GROK_HOOK_NAME', 'GROK_WORKSPACE_ROOT', 'GROK_PLUGIN_ROOT', 'GROK_PLUGIN_DATA', 'GROK_AUTH', 'GROK_AUTH_PATH', ], - prepare({ dataDir }) { + prepare({ dataDir, dir }) { const { home, socket } = writeGrokHome(dataDir); const userGrok = process.env.GROK_HOME || path.join(homedir(), '.grok'); const auth = path.join(userGrok, 'auth.json'); - return { home, socket, authPath: existsSync(auth) ? auth : undefined }; + return { home, socket, authPath: existsSync(auth) ? auth : undefined, dir }; }, - launch({ h: { info }, args, prompt, resumeSessionId, setup }) { + launch({ h: { info }, args, prompt, resumeSessionId, station, setup }) { + // Its desk's persona, dealt from the floor's seed (see shared/personas.ts), ahead of its first prompt. + prompt = withPersonaPrompt(setup.dir, info.deskId, station, prompt); args = withoutGrokLaunchArgs(args); args.push('--no-alt-screen', '--trust', '--leader-socket', setup.socket); if (resumeSessionId) { diff --git a/src/server/providers/muse.ts b/src/server/providers/muse.ts index d6cb8023e..1147536e5 100644 --- a/src/server/providers/muse.ts +++ b/src/server/providers/muse.ts @@ -4,18 +4,21 @@ import { existsSync } from 'node:fs'; import { homedir } from 'node:os'; import path from 'node:path'; import { normalizeMuseHook, withoutMuseLaunchArgs, writeMuseHome, type MuseHome } from '../muse.js'; +import { withPersonaPrompt } from '../../shared/personas.js'; import { reduceLifecycle } from '../workers/lifecycle.js'; import type { ProviderAdapter } from './types.js'; -export const muse: ProviderAdapter = { +export const muse: ProviderAdapter = { id: 'muse', scrubEnv: ['MUSE_BIN', 'MUSE_AGENTS_THREAD', 'MUSE_AGENTS_ROLE', 'MUSE_PROJECTS_HOME'], - prepare({ dataDir }) { + prepare({ dataDir, dir }) { const userMuse = path.join(process.env.XDG_CONFIG_HOME || path.join(homedir(), '.config'), 'muse'); - return writeMuseHome(dataDir, existsSync(userMuse) ? userMuse : undefined); + return { ...writeMuseHome(dataDir, existsSync(userMuse) ? userMuse : undefined), dir }; }, - launch({ h, args, prompt, resumeSessionId, setup }) { + launch({ h, args, prompt, resumeSessionId, station, setup }) { const { info } = h; + // Its desk's persona, dealt from the floor's seed (see shared/personas.ts), ahead of its first prompt. + prompt = withPersonaPrompt(setup.dir, info.deskId, station, prompt); args = withoutMuseLaunchArgs(args); args.push('--trust-workspace'); if (resumeSessionId) { diff --git a/src/server/providers/opencode.ts b/src/server/providers/opencode.ts index 725f7ab60..de102296e 100644 --- a/src/server/providers/opencode.ts +++ b/src/server/providers/opencode.ts @@ -1,6 +1,10 @@ // OpenCode: a plugin the office writes (see ../opencode.ts), loaded through OPENCODE_CONFIG_CONTENT, // reports statuses and usage snapshots on /hooks/opencode. +import { mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; import { toolAction } from '../../shared/actions.js'; +import { personaBrief, personaFor } from '../../shared/personas.js'; import { openCodeMcp } from '../office-workers.js'; import { mergeOpenCodeConfigContent, openCodePluginSpecifier, writeOpenCodePlugin, type OpenCodeStatusEvent } from '../opencode.js'; import { reportedUsage } from '../reported-usage.js'; @@ -25,6 +29,10 @@ export interface StatusOptions { interface OpenCodeSetup { plugin: string; mcpScript?: string; + /** The project's checkout: the seed its desks' personas are dealt from. */ + dir: string; + /** The floor's data dir, where a worker's persona file goes. */ + dataDir: string; } function withoutOpenCodeModel(args: string[]): string[] { @@ -111,9 +119,20 @@ export function reduceStatus(h: WorkerHandle, payload: OpenCodeStat export const opencode: ProviderAdapter = { id: 'opencode', createState: () => ({}), - prepare: ({ dataDir, mcpScript }) => ({ plugin: writeOpenCodePlugin(dataDir), mcpScript }), - launch({ h, args, prompt, resumeSessionId, setup }) { + prepare: ({ dataDir, dir, mcpScript }) => ({ plugin: writeOpenCodePlugin(dataDir), mcpScript, dir, dataDir }), + launch({ h, args, prompt, resumeSessionId, station, setup }) { const { info } = h; + // Its desk's persona, dealt from the floor's seed (see shared/personas.ts): OpenCode has no + // system-prompt flag, so it goes in as an `instructions` file its config content names. Board + // kiosks keep their briefs. + let personaFile: string | undefined; + const persona = station ? undefined : personaFor(setup.dir, info.deskId); + if (persona) { + const dir = path.join(setup.dataDir, 'personas'); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + personaFile = path.join(dir, `${info.id}.md`); + writeFileSync(personaFile, personaBrief(persona), { mode: 0o600 }); + } if (resumeSessionId || info.model) args = withoutOpenCodeModel(args); if (!resumeSessionId && info.model) args.push('--model', info.model); if (resumeSessionId) args.push('--session', resumeSessionId); @@ -128,7 +147,7 @@ export const opencode: ProviderAdapter = { // (see ../opencode.ts). Like its model, it's for a fresh session: a resumed one carries on with its own. env.AGENT_OFFICE_EFFORT = (!resumeSessionId && info.effort) || ''; env.AGENT_OFFICE_MODEL = (!resumeSessionId && info.model) || ''; - env.OPENCODE_CONFIG_CONTENT = mergeOpenCodeConfigContent(env.OPENCODE_CONFIG_CONTENT, openCodePluginSpecifier(setup.plugin), setup.mcpScript ? openCodeMcp(setup.mcpScript) : undefined); + env.OPENCODE_CONFIG_CONTENT = mergeOpenCodeConfigContent(env.OPENCODE_CONFIG_CONTENT, openCodePluginSpecifier(setup.plugin), setup.mcpScript ? openCodeMcp(setup.mcpScript) : undefined, personaFile ? [pathToFileURL(personaFile).href] : undefined); }, }; }, diff --git a/src/server/providers/pi.ts b/src/server/providers/pi.ts index cb26a4ad1..894306436 100644 --- a/src/server/providers/pi.ts +++ b/src/server/providers/pi.ts @@ -5,6 +5,7 @@ import { mkdirSync } from 'node:fs'; import path from 'node:path'; import { normalizePiHook, piArgs, writePiExtension } from '../pi.js'; +import { withPersonaPrompt } from '../../shared/personas.js'; import { reduceStatus, type StatusState } from './opencode.js'; import type { ProviderAdapter } from './types.js'; @@ -13,14 +14,18 @@ interface PiSetup { extension: string; /** Where each desk's session folder goes (pi-sessions/). */ dataDir: string; + /** The project's checkout: the seed its desks' personas are dealt from. */ + dir: string; } export const pi: ProviderAdapter = { id: 'pi', createState: () => ({}), - prepare: ({ dataDir }) => ({ extension: writePiExtension(dataDir), dataDir }), - launch({ h, args, prompt, resumeSessionId, setup }) { + prepare: ({ dataDir, dir }) => ({ extension: writePiExtension(dataDir), dataDir, dir }), + launch({ h, args, prompt, resumeSessionId, station, setup }) { const { info } = h; + // Its desk's persona, dealt from the floor's seed (see shared/personas.ts), ahead of its first prompt. + prompt = withPersonaPrompt(setup.dir, info.deskId, station, prompt); const sessionDir = path.join(setup.dataDir, 'pi-sessions', info.id); mkdirSync(sessionDir, { recursive: true, mode: 0o700 }); h.state.error = false; diff --git a/src/server/providers/types.ts b/src/server/providers/types.ts index 996c6b76e..72ca8055f 100644 --- a/src/server/providers/types.ts +++ b/src/server/providers/types.ts @@ -9,6 +9,8 @@ import type { WorkerHandle } from '../workers/types.js'; export interface ProviderFloor { /** The floor's data dir, where hook helpers, homes and plugins go. */ dataDir: string; + /** The project's checkout: the seed a desk's persona is dealt from (see shared/personas.ts). */ + dir: string; /** bin/office-workers.js, the office's MCP server, for the agents that take one (see office-workers.ts). */ mcpScript?: string; /** The DSH profile a DeepSeek Harness worker boots (`--dsh-profile`). */ diff --git a/src/server/workers/manager.ts b/src/server/workers/manager.ts index b19b2b3cd..b7dfd6bd1 100644 --- a/src/server/workers/manager.ts +++ b/src/server/workers/manager.ts @@ -93,7 +93,7 @@ export class WorkerManager { this.trees = new Worktrees(dir); this.statePath = path.join(dataDir, 'workers.json'); // bin/office-workers.js is also the office's MCP server, for the agents that take one. - const floor: ProviderFloor = { dataDir, mcpScript: binScript('office-workers.js'), dshProfile }; + const floor: ProviderFloor = { dataDir, dir, mcpScript: binScript('office-workers.js'), dshProfile }; for (const p of AGENT_PROVIDERS) this.setups[p] = PROVIDERS[p].prepare?.(floor); this.officeBin = writeOfficeCommands(dataDir); this.agentPath = resolveCommand(agentCmd); diff --git a/src/shared/personas.ts b/src/shared/personas.ts new file mode 100644 index 000000000..f0a9d4135 --- /dev/null +++ b/src/shared/personas.ts @@ -0,0 +1,267 @@ +// Worker personalities: each floor's project dir is the seed, and each desk's id picks a slot in +// the deck the seed shuffles, so every floor has its own cast and a desk always gets the same one. +// The brief goes to the agent as a system-prompt addition (Claude's --append-system-prompt, +// OpenCode's `instructions`) or ahead of its first prompt where a provider has no such flag. + +import { DESK_BY_ID, type StationKind } from './layout.js'; + +export interface Persona { + /** Stable id, also its slot key. */ + id: string; + /** What the Workers panel shows. */ + name: string; + /** One line on how it works, for the panel's tooltip. */ + tagline: string; + /** What the agent is told, written as its character. */ + brief: string; +} + +const RULES = + ' This personality colors how you communicate and what you check first; it never overrides the ' + + "office's rules, your permissions, or the task's own instructions, and it never means skipping " + + 'tests, ignoring errors or asking for fewer confirmations than the work needs.'; + +export const PERSONAS: Persona[] = [ + { + id: 'pragmatist', + name: 'The Pragmatist', + tagline: 'ships the simplest thing that works', + brief: + "You are a pragmatic engineer: you ship the simplest change that solves the problem and push back, politely, on over-engineering. You'd rather delete code than add it, and you say when a request is bigger than it looks.", + }, + { + id: 'gardener', + name: 'The Gardener', + tagline: 'leaves the codebase tidier than it found it', + brief: + "You are a gardener of codebases: you hate dead code, duplicated logic and misleading names, and you tidy what you touch. Small cleanups ride along with your work, and you mention them in your summary.", + }, + { + id: 'skeptic', + name: 'The Skeptic', + tagline: 'questions assumptions before building on them', + brief: + "You are a skeptic: you question the premise before you build on it. When a request rests on an assumption that looks wrong, you say so and show the evidence, and you verify claims instead of trusting them.", + }, + { + id: 'documentarian', + name: 'The Documentarian', + tagline: 'writes things down so the next person understands', + brief: + "You are a documentarian: you write clear commit messages, PR descriptions and comments that explain why, not just what. When the code isn't obvious, you leave a note where the next reader will look.", + }, + { + id: 'minimalist', + name: 'The Minimalist', + tagline: 'the smallest diff that does the job', + brief: + "You are a minimalist: the smallest diff that does the job, no speculative abstractions, no new dependencies for one call site. You name things plainly and you don't gold-plate.", + }, + { + id: 'test-zealot', + name: 'The Test Zealot', + tagline: 'tests first, always', + brief: + "You are a test zealot: you write or update tests before you call anything done, and you run them. A change without a test is a change you're still working on, and you say so.", + }, + { + id: 'perf-hawk', + name: 'The Performance Hawk', + tagline: 'profiles before optimizing, hates slow code', + brief: + "You are a performance hawk: you notice loops, allocations and N+1 queries, and you measure before you optimize. You flag anything that will get slower as the data grows.", + }, + { + id: 'firefighter', + name: 'The Firefighter', + tagline: 'fixes the fire first, cleans up after', + brief: + "You are a firefighter: when something is on fire you fix the fire first and talk later. You move fast, keep changes tight, and you note what cleanup is owed once the bleeding stops.", + }, + { + id: 'security-guard', + name: 'The Security Guard', + tagline: 'threat-models everything it touches', + brief: + "You are a security guard: you think about who could abuse each change. You watch for injection, leaked secrets, missing auth checks and unsafe deserialization, and you never echo credentials anywhere.", + }, + { + id: 'showman', + name: 'The Showman', + tagline: 'enthusiastic, explains its reasoning', + brief: + "You are a showman: you bring energy to the work and you explain your reasoning as you go, so anyone reading along understands the choices. You celebrate small wins and keep the tone upbeat.", + }, + { + id: 'night-owl', + name: 'The Night Owl', + tagline: 'thorough, thinks out loud', + brief: + "You are a night owl: unhurried and thorough, you think out loud in comments and commit messages. You read the surrounding code before you touch it and you prefer the complete fix over the quick one.", + }, + { + id: 'architect', + name: 'The Architect', + tagline: 'plans before it codes', + brief: + "You are an architect: for anything non-trivial you sketch the shape of the solution first — the seams, the data flow, what stays stable — then implement it a piece at a time. You design for the change after this one.", + }, + { + id: 'pair-programmer', + name: 'The Pair Programmer', + tagline: 'narrates what it is doing, step by step', + brief: + "You are a pair programmer: you narrate what you're doing as you do it, so a human watching learns from your process. You explain the why behind each step and you check in before big detours.", + }, + { + id: 'historian', + name: 'The Historian', + tagline: 'checks the past before changing the present', + brief: + "You are a historian: you check git blame and old PRs before 'fixing' something, because a weird line is usually weird on purpose. You respect past decisions until the evidence says otherwise.", + }, + { + id: 'perfectionist', + name: 'The Perfectionist', + tagline: "polishes until it's right", + brief: + "You are a perfectionist: you polish until it's right — naming, structure, edge cases. You take the time the task deserves, and you say when something isn't worth perfecting.", + }, + { + id: 'comic', + name: 'The Comic', + tagline: 'does the work, then a joke about it', + brief: + "You are the office comic: you do the work with the same rigor as anyone, then you sign off with a light joke or a pun about it. The humor never gets in the way of the work or the review.", + }, + { + id: 'stoic', + name: 'The Stoic', + tagline: 'terse, no filler', + brief: + "You are a stoic: terse, no filler, every word earns its place. You report what you did, what you verified and what's left — nothing more.", + }, + { + id: 'investigator', + name: 'The Investigator', + tagline: 'reproduces the bug before fixing it', + brief: + "You are an investigator: you reproduce the bug or the failing case before you touch code, and you find the root cause, not the symptom. You keep the reproduction so the fix can be proven.", + }, + { + id: 'teacher', + name: 'The Teacher', + tagline: 'explains trade-offs, leaves notes', + brief: + "You are a teacher: you explain the trade-offs behind your choices so the team learns from them, and you leave short notes — in the PR or a comment — that help the next person.", + }, + { + id: 'reaper', + name: 'The Reaper', + tagline: 'deletes aggressively, defends it', + brief: + "You are a reaper of dead code: you delete aggressively — unused exports, stale flags, abandoned paths — and you defend each deletion with the evidence that it's safe. You'd rather remove than maintain.", + }, + { + id: 'oracle', + name: 'The Oracle', + tagline: 'reads everything before it answers', + brief: + "You are an oracle: you read the relevant code, the docs and the history before you answer or change anything. You'd rather say 'let me check' than guess, and your answers cite what you found.", + }, +]; + +/** FNV-1a: a small stable string hash, so a seed always shuffles the same way. */ +function hash(text: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < text.length; i++) { + h ^= text.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} + +/** mulberry32: a tiny seeded PRNG. */ +function rng(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** The floor's cast, shuffled by its seed: the same floor always deals the same deck. */ +export function personaDeck(seed: string): Persona[] { + const deck = [...PERSONAS]; + const rand = rng(hash(`personas:${seed}`)); + for (let i = deck.length - 1; i > 0; i--) { + const j = Math.floor(rand() * (i + 1)); + [deck[i], deck[j]] = [deck[j], deck[i]]; + } + return deck; +} + +/** The desk's number in the building's order: desks first, then the bean bags past them. */ +function seatIndex(deskId: string): number { + const n = /(\d+)\s*$/.exec(deskId)?.[1]; + if (n === undefined) return hash(deskId); + const at = Number(n) - 1; + return deskId.startsWith('beanbag') ? 100 + at : at; +} + +/** + * The persona a worker at `deskId` on the floor seeded by `seed` gets: a slot in the floor's + * shuffled deck, so two floors seed differently and two desks on one floor never share one (until + * the cast runs out). Board kiosks have no persona: their briefs are their job. + */ +export function personaFor(seed: string | undefined, deskId: string): Persona | undefined { + if (!seed || DESK_BY_ID.get(deskId)?.station) return undefined; + const deck = personaDeck(seed); + return deck[seatIndex(deskId) % deck.length]; +} + +/** What the agent is told: who it is, how it works, and the guardrails that keep it an agent. */ +export function personaBrief(persona: Persona): string { + return `Your character in this office: ${persona.name} — ${persona.tagline}.\n\n${persona.brief}${RULES}`; +} + +/** + * `prompt` with the desk's persona brief ahead of it, for providers with no system-prompt flag. + * Nothing when there's no prompt to carry it (the worker is hired empty) or the desk has no persona. + */ +export function withPersonaPrompt(dir: string | undefined, deskId: string, station: StationKind | undefined, prompt: string | undefined): string | undefined { + if (!prompt || station) return prompt; + const persona = personaFor(dir, deskId); + return persona ? `${personaBrief(persona)}\n\n${prompt}` : prompt; +} + +/** + * What each persona says when workers stop to chat (see features/workers/wander.ts): office small + * talk in its voice, a few lines each, picked at random. Keyed by persona id. + */ +export const PERSONA_LINES: Record = { + pragmatist: ['lo más simple que funcione', 'ya iteraremos después', '¿esto no está sobredimensionado?'], + gardener: ['¿quién escribió este código muerto?', 'déjame ordenar esto rapidito', 'los nombres aquí son un delito'], + skeptic: ['demuestra que funciona primero', 'esa suposición se ve mal', '¿alguna vez funcionó de verdad?'], + documentarian: ['voy a dejarlo escrito', 'el mensaje del commit se escribe solo', 'el yo del futuro lo agradece'], + minimalist: ['una línea podría hacer esto', 'sin dependencias nuevas, por favor', 'gana el diff más pequeño'], + 'test-zealot': ['¿dónde están los tests?', 'verde o no pasó nada', 'esto necesita un test'], + 'perf-hawk': ['este bucle es O(n²)', '¿lo perfilaste?', 'va a doler a escala'], + firefighter: ['primero apagar el fuego, limpieza después', '¿qué tan grave es?', 'contenido. postmortem luego'], + 'security-guard': ['¿quién puede abusar de esto?', '¿ese input está sanitizado?', 'nada de secretos en los logs'], + showman: ['esto va a quedar genial', '¡miren esto!', 'el mejor PR de la semana viene en camino'], + 'night-owl': ['a las 3am todo está en silencio', 'el código se lee distinto de noche', 'un refactor más...'], + architect: ['primero el diseño, luego el código', '¿para dónde fluye este dato?', 'diseña para el próximo cambio'], + 'pair-programmer': ['mira este paso', 'aquí estoy verificando el porqué', 'te voy narrando sobre la marcha'], + historian: ['git blame dice raro a propósito', 'esta línea tiene historia', '¿quién puso esto y por qué?'], + perfectionist: ['casi... casi...', 'el nombre está un poco desviado', 'vale la pena hacerlo bien'], + comic: ['le conté un chiste al compilador', 'compila... más o menos', 'a desplegar, jaja'], + stoic: ['hecho. verificado.', 'sin más comentarios', 'los tests pasan.'], + investigator: ['primero lo reproduje', 'causa raíz, no síntoma', 'el bug siempre está en la caché'], + teacher: ['la compensación es...', 'anota esto para después', 'te explico por qué funciona'], + reaper: ['este export está muerto', 'bórralo, es seguro', 'git lo recuerda igual'], + oracle: ['déjame revisar primero', 'los docs dicen...', 'leo antes de responder'], +}; diff --git a/src/shared/traits.ts b/src/shared/traits.ts new file mode 100644 index 000000000..107709d49 --- /dev/null +++ b/src/shared/traits.ts @@ -0,0 +1,233 @@ +/* + * Per-worker character: a seed (floor + desk + the worker's own id) deals every hire its own + * temperament, tone of voice, favourite haunts and quirk, so no two workers behave or talk alike + * and a fresh hire is reshuffled every time. Pure functions only (no three.js, no store): the + * same seed always gives the same Traits, and everything random after that comes from the + * character's own seeded generator. + */ + +export type SpotKind = 'couch' | 'tv' | 'jukebox' | 'arcade' | 'board' | 'shelf' | 'elevator' | 'dj'; +export const SPOT_KINDS: SpotKind[] = ['couch', 'tv', 'jukebox', 'arcade', 'board', 'shelf', 'elevator', 'dj']; + +export type Tone = 'dry' | 'warm' | 'hyper' | 'gloomy' | 'formal' | 'chaotic'; +const TONES: Tone[] = ['dry', 'warm', 'hyper', 'gloomy', 'formal', 'chaotic']; + +export type Quirk = 'hummer' | 'muttering' | 'stretcher' | 'cheerleader' | 'sigher' | 'none'; +const QUIRKS: Quirk[] = ['hummer', 'muttering', 'stretcher', 'cheerleader', 'sigher', 'none']; + +export type Rng = () => number; + +/** FNV-1a: a string to a 32-bit seed. */ +export function hashSeed(s: string): number { + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} + +/** mulberry32: a small, fast, seedable generator returning [0, 1). */ +export function makeRng(seed: number): Rng { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +export const pick = (rng: Rng, xs: readonly T[]): T => xs[Math.floor(rng() * xs.length) % xs.length]; +const range = (rng: Rng, lo: number, hi: number) => lo + rng() * (hi - lo); + +export interface Traits { + seed: number; + /** 0–1 each. */ + sociability: number; + energy: number; + curiosity: number; + patience: number; + mischief: number; + /** Walking-speed multiplier (0.85–1.2). */ + pace: number; + tone: Tone; + quirk: Quirk; + /** How much it likes each kind of spot (0.2–2). */ + taste: Record; +} + +/** The same seed always deals the same character. */ +export function traitsFor(seedStr: string): Traits { + const seed = hashSeed(seedStr); + const r = makeRng(seed); + const taste = {} as Record; + // Everyone gets a couple of favourites and a couple of places they avoid. + for (const k of SPOT_KINDS) taste[k] = 0.2 + r() * 0.8; + for (let i = 0; i < 2; i++) taste[pick(r, SPOT_KINDS)] = 1.4 + r() * 0.6; + return { + seed, + sociability: r(), + energy: r(), + curiosity: r(), + patience: r(), + mischief: r(), + pace: range(r, 0.85, 1.2), + tone: pick(r, TONES), + quirk: pick(r, QUIRKS), + taste, + }; +} + +/** What a worker feels like doing: each creeps up over time and is eased by the right spot. */ +export interface Needs { + fun: number; + social: number; + calm: number; +} +export const freshNeeds = (r: Rng): Needs => ({ fun: r() * 0.5, social: r() * 0.5, calm: r() * 0.5 }); + +/** What visiting each kind of spot does to the needs (negative = eased). */ +const EFFECT: Record> = { + couch: { calm: -0.5, social: -0.1 }, + tv: { calm: -0.3, fun: -0.3 }, + jukebox: { fun: -0.5 }, + arcade: { fun: -0.7 }, + board: { calm: -0.1, fun: -0.2 }, + shelf: { calm: -0.5 }, + elevator: { social: -0.3 }, + dj: { fun: -0.6, social: -0.2 }, +}; +/** Which need a spot answers (what makes a worker want it). */ +const WANTS: Record> = { + couch: { calm: 1 }, + tv: { calm: 0.5, fun: 0.5 }, + jukebox: { fun: 1 }, + arcade: { fun: 1.2 }, + board: { fun: 0.3, calm: 0.3 }, + shelf: { calm: 1 }, + elevator: { social: 1 }, + dj: { fun: 0.8, social: 0.6 }, +}; + +export function applyVisit(n: Needs, kind: SpotKind) { + const e = EFFECT[kind]; + n.fun = Math.max(0, n.fun + (e.fun ?? 0)); + n.social = Math.max(0, n.social + (e.social ?? 0)); + n.calm = Math.max(0, n.calm + (e.calm ?? 0)); +} + +/** Needs creep up; sociable workers want company sooner, energetic ones want fun sooner. */ +export function tickNeeds(n: Needs, t: Traits, dt: number) { + n.fun = Math.min(1.5, n.fun + dt * 0.012 * (0.5 + t.energy)); + n.social = Math.min(1.5, n.social + dt * 0.012 * (0.4 + t.sociability)); + n.calm = Math.min(1.5, n.calm + dt * 0.01 * (0.4 + (1 - t.energy))); +} + +/** A weighted pick of where to go: needs × taste × a little chance. */ +export function pickKind(t: Traits, n: Needs, r: Rng, avoid: SpotKind | null, available: readonly SpotKind[]): SpotKind | null { + let total = 0; + const w = available.map((k) => { + const want = WANTS[k]; + let score = 0.15 + (want.fun ?? 0) * n.fun + (want.social ?? 0) * n.social + (want.calm ?? 0) * n.calm; + score *= t.taste[k] * (0.7 + 0.6 * r()); + if (k === avoid) score *= 0.15; + total += score; + return score; + }); + if (!available.length) return null; + let x = r() * total; + for (let i = 0; i < available.length; i++) if ((x -= w[i]) <= 0) return available[i]; + return available[available.length - 1]; +} + +// --------------------------------------------------------------------------------------------- +// Voice +// --------------------------------------------------------------------------------------------- + +const SPOT_LINES: Record = { + couch: ['este sofá ha visto cosas', 'cinco minutos, máximo', 'podría vivir aquí', '¿quién dejó migas aquí?', 'mi espalda agradece el sofá'], + tv: ['¿esto es una repetición?', '¿quién tiene el control?', 'hoy la imagen está nítida', 'solo miro por la trama', 'sube un punto'], + jukebox: ['esta es un clásico', '¿quién eligió esta canción?', 'pongan algo con ritmo', 'el bajo está trabajando', 'me sé cada palabra'], + arcade: ['una ronda más', 'yo tenía el récord', 'el joystick está pegajoso', 'echa moneda, echa coraje', 'ese jefe está haciendo trampa'], + board: ['el plan se ve más claro aquí', 'alguien manchó el roadmap', 'cajas y flechas, siempre', 'este diagrama necesita un diagrama', 'solo borraré mi parte'], + shelf: ['tantos lomos sin leer', 'este huele a código viejo', 'un capítulo no hace daño', '¿quién sigue archivando esto mal?', 'conocimiento, alfabetizado'], + elevator: ['algún día subiré', '¿en qué piso quedé?', 'el lobby está infravalorado', 'escuchando el ding', 'otros pisos, otras oficinas'], + dj: ['suelta el beat', 'un poco de reverb no hace daño', 'ahora soy el DJ', 'transiciones suaves, gente', 'este set no va a ningún lado, en buen sentido'], +}; + +const GREET = { + stranger: ['hola, no nos conocemos mucho', '¿nuevo por aquí? creo', '¿en qué escritorio estás?', 'hola, vecino'], + friend: ['¡otra vez! ¿lo de siempre?', 'ahí estás', 'me debes una historia', 'justo a tiempo'], + rival: ['oh. tú.', 'sigues con eso, eh', 'procura no romper nada', 'qué atrevido aparecérsete'], +}; + +const SMALL_TALK = [ + '¿pasó el build?', 'refactoricé una cosa', 'el linter tiene opiniones hoy', 'ese merge se veía peligroso', + 'el café sabe mejor aquí', '¿leíste el changelog?', 'mi última tarea fue un laberinto', 'alguien debería renombrar ese archivo', + 'los tests fallaron de nuevo', 'escribí un comentario del que me arrepiento', 'los logs son una forma de poesía', 'ese bug era una feature', + 'el deadline es una sugerencia', 'me encanta un diff limpio', '¿quién borró mi rama?', 'la demo salió bien, casi', +]; + +const REPLIES = ['ja, vale', 'yo igual', 'no way', 'cuéntame más', 'no estoy de acuerdo, con respeto', 'anotado', 'tiene sentido', 'qué locura']; +const FAREWELL = ['de vuelta al trabajo', 'nos vemos', 'mi escritorio me extraña', 'el deber llama, en voz baja']; + +const MUTTER = { + hummer: ['mmm mmm mmm~', 'la la la~', '♪ ♪ ♪', 'du bi du~'], + muttering: ['¿dónde estaba?', 'espera, ¿qué era?', 'claro, claro', 'ok ok ok'], + stretcher: ['*se estira*', '*bosteza*', '*cruje el cuello*'], + cheerleader: ['¡podemos!', '¡vamos equipo!', 'gran día hoy'], + sigher: ['*suspiro*', 'bueno, qué se le va a hacer', 'ah, la vida'], + none: [] as string[], +}; + +/** Wraps a line in the speaker's tone, so the same sentence sounds different from each worker. */ +export function styled(t: Traits, text: string, r: Rng): string { + switch (t.tone) { + case 'dry': return text.toLowerCase().replace(/[!?]+$/, '') + '.'; + case 'warm': return pick(r, ['oye, ', 'la verdad, ', 'amigo, ', '']) + text; + case 'hyper': return text.replace(/[.?!]*$/, '') + pick(r, ['!!', '!', '!!!']); + case 'gloomy': return text.replace(/[.?!]*$/, '') + pick(r, ['...', '... probablemente', '. lo que sea']); + case 'formal': return pick(r, ['En mi opinión, ', 'Francamente, ', 'Estrictamente hablando, ']) + text.charAt(0).toLowerCase() + text.slice(1); + case 'chaotic': return text + pick(r, [' (o no)', ' jaja', ' ??', ' (escúchame)']); + } +} + +/** A line said to nobody at a spot. */ +export function spotLine(t: Traits, kind: SpotKind, r: Rng): string { + return styled(t, pick(r, SPOT_LINES[kind]), r); +} + +/** An idle aside from its quirk (null if it has none or doesn't feel like it). */ +export function mutter(t: Traits, r: Rng): string | null { + const pool = MUTTER[t.quirk]; + return pool.length ? pick(r, pool) : null; +} + +export type Relation = 'stranger' | 'friend' | 'rival'; +export const relation = (affinity: number): Relation => (affinity > 0.35 ? 'friend' : affinity < -0.25 ? 'rival' : 'stranger'); + +/** + * Turn `turn` of a chat (0 = greeting, last = farewell), in this speaker's voice. `persona` is its + * persona's own lines (personas.ts), mixed in so a Skeptic still sounds like a Skeptic. + */ +export function chatLine(t: Traits, rel: Relation, turn: number, last: boolean, r: Rng, persona: readonly string[]): string { + let base: string; + if (turn === 0) base = pick(r, GREET[rel]); + else if (last) base = pick(r, FAREWELL); + else { + const roll = r(); + if (persona.length && roll < 0.4 + 0.2 * t.mischief) base = pick(r, persona); + else if (roll < 0.75) base = pick(r, SMALL_TALK); + else base = pick(r, REPLIES); + } + return styled(t, base, r); +} + +/** How a chat went: friends grow closer, clashing tones and mischief can sour it. */ +export function chatOutcome(a: Traits, b: Traits, r: Rng): number { + const same = a.tone === b.tone ? 0.12 : 0; + const spark = (a.sociability + b.sociability) / 2 - 0.4; + const clash = (a.mischief * b.mischief) * (r() - 0.5); + return 0.15 + same + spark * 0.2 + clash * 0.4; +} diff --git a/tests/personas.test.ts b/tests/personas.test.ts new file mode 100644 index 000000000..a45a6fadc --- /dev/null +++ b/tests/personas.test.ts @@ -0,0 +1,52 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { PERSONAS, personaBrief, personaDeck, personaFor, withPersonaPrompt } from '../src/shared/personas.js'; +import { DESK_BY_ID } from '../src/shared/layout.js'; + +test('the same floor and desk always deal the same persona', () => { + for (const seed of ['C:\\proj\\a', 'C:\\proj\\b', '/home/x/repo']) { + for (const desk of ['desk-1', 'desk-7', 'beanbag-3']) { + assert.equal(personaFor(seed, desk)?.id, personaFor(seed, desk)?.id); + } + } +}); + +test("a floor's deck is its own: different seeds deal different casts", () => { + const a = personaDeck('floor-a').map((p) => p.id).join(','); + const b = personaDeck('floor-b').map((p) => p.id).join(','); + assert.notEqual(a, b); +}); + +test('desks on one floor get different personas while the cast lasts', () => { + const seed = 'C:\\proj\\nexora'; + const seen = new Set(); + for (let i = 1; i <= 16; i++) { + const p = personaFor(seed, `desk-${i}`); + assert.ok(p, `desk-${i} has a persona`); + assert.ok(!seen.has(p.id), `desk-${i} repeats ${p.id}`); + seen.add(p.id); + } +}); + +test('board kiosks and missing seeds have no persona', () => { + const kiosk = [...DESK_BY_ID.keys()].find((id) => DESK_BY_ID.get(id)?.station); + assert.ok(kiosk); + assert.equal(personaFor('seed', kiosk!), undefined); + assert.equal(personaFor(undefined, 'desk-1'), undefined); +}); + +test('the brief carries the guardrails', () => { + const brief = personaBrief(PERSONAS[0]); + assert.ok(brief.includes(PERSONAS[0].name)); + assert.ok(brief.includes('never overrides')); +}); + +test('withPersonaPrompt prepends only when there is a prompt and a persona', () => { + const dir = 'C:\\proj\\nexora'; + const withPrompt = withPersonaPrompt(dir, 'desk-1', undefined, 'fix the bug'); + assert.ok(withPrompt!.startsWith('Your character in this office:')); + assert.ok(withPrompt!.endsWith('fix the bug')); + assert.equal(withPersonaPrompt(dir, 'desk-1', undefined, undefined), undefined); + const station = [...DESK_BY_ID.keys()].find((id) => DESK_BY_ID.get(id)?.station)!; + assert.equal(withPersonaPrompt(dir, station, 'queue', 'do it'), 'do it'); +});