@homie-rocks/studio 0.24.3 → 0.24.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -17,9 +17,59 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.24.5] - 2026-10-03
21
+
22
+ **Plugin 0.25.5** · [#39](https://github.com/homie-rocks/homie/pull/39)
23
+
24
+ Room chat keeps off your game's HUD: on a phone the Chat pill is a round icon, as the room button is, and your game can say where the strip of new lines sits, or keep new lines in the Chat sheet, so a game with a busy HUD keeps typed chat.
25
+
26
+ ### Added
27
+
28
+ - game.json `"screen": { "chat": … }` says where room chat's strip of new lines sits on each screen: `at` (a corner, `top-center` or `bottom-center`), `x` / `y` to move it in from its side and its edge, and `"lines": "sheet-only"` to keep new lines in the Chat sheet while the pill counts them. `desk`, `phone`, `sideways` and `tv` (the big screen's corner of lines) each take their own, changing only what they name. A game whose HUD or controls sit where the strip shows no longer has to keep chat to emoji to stay clear. `chat/CHAT.md` has every field; the template's AGENTS.md and the game skill say when to use it.
29
+ - `homie-studio build` warns about a wrong field in `screen.chat` (the play page uses the default there), and says so when a place or `"sheet-only"` is written in game.json `"chat"` (the room's rules) instead.
30
+
31
+ ### Changed
32
+
33
+ - The Chat pill follows the room button: on a phone (either way up), and beside a room button your game keeps an icon (`screen.share` `"label": false`), it is a round icon with the count of new lines on its corner. On a computer it still says "Chat", unless the word would take the room button's band off the screen or into the middle third of its width, where games keep a clock or a title; then it stays the icon until the window changes size.
34
+
35
+ ### Fixed
36
+
37
+ - On a phone, the Chat pill's word no longer covers the HUD beside the room button: the first digit of a clock, the last letter of a title.
38
+
39
+ ### Upgrade notes
40
+
41
+ - Nothing to do: a game without `screen.chat` shows the strip where it did. If you kept a game's chat to emoji only so the strip stayed off its HUD, tell Claude: "Put room chat where my game has room, and let players type again." It places the strip with `screen.chat` and resets the game's chat rules (`homie-studio chat rules <game> --reset`).
42
+
43
+ ## [0.24.4] - 2026-10-03
44
+
45
+ **Plugin 0.25.4** · [#37](https://github.com/homie-rocks/homie/pull/37) · [release-2026-10-03-studio-0.24.4](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-03-studio-0.24.4)
46
+
47
+ Your AI guides think with Cloudflare's Clef decision model, which does what a player asks; Clef runs on your own computer while you build; and your games can ask it for their own decisions (tactics, a director's call, a turn) in about a quarter of a second.
48
+
49
+ ### Added
50
+
51
+ - **Clef thinks for your AI guides.** On a server whose guides use Workers AI, the default model is now `@cf/cloudflare/clef-flash`, Cloudflare's open-source decision model (Apache 2.0). It never writes text: each decision is asked as typed Choices of your `agents.json` (which goal, which quest or place or player, which line or none), and its answer is checked by the same rules as before. On 64 recorded Ember Vale moments it did what an open ask asked 36 times out of 40 by itself (the old Llama 3.1 8B: 0 of 40), and blind judges preferred its decisions in 51 of 64 (Llama's in none). In a live room it answered all 9 asks itself.
52
+ - `homie-studio agents try <game> --view <file.json> [--ask ask_help:quest=king-slime --from 0] [--model <id>]`: what your guides' brain would decide in one moment, with no seat taken: the decision, the brain's own pick before the fixed rules, how sure it was, how long it took and what it cost. Use it to test an `agents.json` or to compare a model before setting it.
53
+ - **Clef on your own computer.** With [Ollama](https://ollama.com) 0.35.1 or later and `clef-flash` on this computer, `homie-studio dev` runs the guides, chat review and your game's decisions on it: free, nothing sent to Cloudflare (`--no-local-ai` turns it off). `homie-studio agents sit <game> --brain local` (the MCP's `agent_sit { brain: "local" }`) seats a guide that thinks on your computer every few seconds. `setup status` has a "Clef on this computer" row. Nothing ever downloads the model by itself: `ollama pull clef-flash` is about 11 GB, and it is your call.
54
+ - **Your game's own decisions** (NETPLAY.md section 20): `net.decide(state, questions, { floor })` on the host, for a game whose `game.json` says `"decide": true`. Ask a Choice (2 to 26 options), a yes/no or a Score about your game's state; the room asks Clef and answers with option ids and numbers, within the same daily budget as the guides, at most one ask every 3 s per room. It always answers: with your own floor when there is no AI, no budget, or no answer in time. Measured: about a quarter of a second there and back, about 4 neurons for three questions. Ask per beat or per turn, never per frame.
55
+ - Ember Vale's slimes can think (opt-in: set `"decide": true` in its `game.json`): every 6 s the slimes' director picks how they hunt (rush, surround, gang up on the most hurt, regroup at the King), whether a wave comes and how hard to push, and every screen shows what the slimes are up to. On a kids server they never gang up on the most hurt and never push past steady.
56
+ - The office shows each room's game decisions (how many, who answered, the model's time, the neurons), and each guide decision's model, how sure it was and, when a rule overruled it, what the brain itself chose.
57
+
58
+ ### Changed
59
+
60
+ - A player's ask is now answered with the values they asked for: a brain that picks the asked goal with another quest or place is overruled, like one that picks another goal.
61
+ - House guides remember the lines they said and who is new to them: they greet a newcomer once and otherwise stay quiet unless asked or something changes.
62
+ - Clef costs more neurons a decision than Llama (about 9 against 4: it reads its questions and answers, and writes nothing), so the default 8,000 a day is about 900 guide decisions. Set `HOMIE_BRAIN_MODEL` to `@cf/meta/llama-3.1-8b-instruct-fp8-fast` in `wrangler.jsonc` to keep the old model.
63
+ - The doctor's Workers AI check asks Clef one yes/no question (it does not take a chat message).
64
+
65
+ ### Upgrade notes
66
+
67
+ - `npm run deploy` after upgrading: guides on Workers AI switch to Clef at their next decision. Nothing else to do.
68
+ - To try local Clef, install Ollama (0.35.1 or later), say yes to the download, and run `ollama pull clef-flash`; then `npm run dev`.
69
+
20
70
  ## [0.24.3] - 2026-10-03
21
71
 
22
- **Plugin 0.25.3** · [#36](https://github.com/homie-rocks/homie/pull/36)
72
+ **Plugin 0.25.3** · [#36](https://github.com/homie-rocks/homie/pull/36) · [release-2026-10-03-studio-0.24.3](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-03-studio-0.24.3)
23
73
 
24
74
  Set up your shop with Stripe's own tools for AI: your AI makes the products, checks tax, makes the webhook and answers "how are sales?"; you make the account, approve Stripe's pages and paste one key.
25
75
 
package/README.md CHANGED
@@ -284,8 +284,21 @@ npx homie-studio agents pass night-rush --label Claude
284
284
  day); with none, the game's scripted floor. The local MCP's `agent_sit` puts the owner's own Claude in a guide's
285
285
  seat. The AI never types: it picks ids; the room drops anything else.
286
286
  [agents/GUIDES.md](agents/GUIDES.md) is the short path for an existing RPG.
287
+ - **Clef thinks for the guides** (0.24.4): Workers AI's default for them is Cloudflare's decision model
288
+ (`@cf/cloudflare/clef-flash`), which answers a decision as typed Choices (goal, values, line or none) and never
289
+ writes text. On 64 recorded Ember Vale moments it answered 36 of 40 open asks itself (Llama 3.1 8B: none) and blind
290
+ judges preferred it in 51 of 64, at about 9 neurons a decision (Llama: 4). `HOMIE_BRAIN_MODEL` still picks Llama or
291
+ the 27B Clef; `agents try <game> --view <file> [--ask …] [--model …]` shows what the brain would decide in a moment.
292
+ - **Clef on your own computer** (0.24.4): with Ollama and `clef-flash` here, `dev` runs the guides, chat review and
293
+ game decisions on it, free and with no Cloudflare, and `agents sit --brain local` (the MCP's `agent_sit { brain:
294
+ "local" }`) seats a guide that thinks here. Nothing downloads it: `ollama pull clef-flash` is about 11 GB, the
295
+ person's choice (doctor's "Clef on this computer" row says what is there).
296
+ - **A game's own decisions** (0.24.4, NETPLAY.md section 20): `net.decide(state, questions, { floor })` on the host,
297
+ for a game whose game.json says `"decide": true`: tactics for its opponents, an NPC's reaction, a director's call,
298
+ a turn's move, answered by Clef in the room in about a quarter of a second, per beat (never per frame), within the
299
+ AI brains' day; the floor answers whenever it cannot. Ember Vale's slimes pick their tactics this way (opt-in).
287
300
  - Everything is in the studio's own Worker and D1 (migration `0006_studio_servers.sql`). The site's side is in
288
- [site/SITE.md](site/SITE.md); the room's in [netplay/NETPLAY.md](netplay/NETPLAY.md) sections 17 and 18.
301
+ [site/SITE.md](site/SITE.md); the room's in [netplay/NETPLAY.md](netplay/NETPLAY.md) sections 17, 18 and 20.
289
302
 
290
303
  ## Room chat
291
304
 
@@ -303,8 +316,9 @@ npx homie-studio chat remove night-rush pub-3 <line id> # take a
303
316
  npx homie-studio chat words # the built-in word list the floor holds
304
317
  ```
305
318
 
306
- - **The play page:** a Chat pill beside the room button (the corner `screen.share` keeps clear of the HUD), its
307
- sheet (the last minutes, the reactions, the quick lines, typing, Report), a short ticker of new lines, and two
319
+ - **The play page:** a Chat pill beside the room button (the corner `screen.share` keeps clear of the HUD; a round
320
+ icon on a phone), its sheet (the last minutes, the reactions, the quick lines, typing, Report), a short strip of
321
+ new lines (game.json `screen.chat` puts it where the game has room, or keeps new lines in the sheet), and two
308
322
  switches each player keeps: "Show my messages over my character" and "Show chat on this screen". The big screen
309
323
  shows the lines in a corner; the watch page has a Chat button; homie.rocks shows a room's chat on its card.
310
324
  - **Rules** per game (game.json `"chat"`) and per server, in the office or with `chat rules`: off, emoji, quick
@@ -314,7 +328,8 @@ npx homie-studio chat words # the bu
314
328
  - **Moderation, in the studio's own Worker, before anyone sees a typed line:** a built-in word list and patterns
315
329
  (slurs, sexual words, self-harm, personal questions and contact details, links, swears, your own words), then
316
330
  Cloudflare's **Clef** decision model on the studio's own Workers AI (`@cf/cloudflare/clef-flash`, about 2.3
317
- neurons a message measured, 2,000 a day by default: inside the free allocation). Emoji and quick lines are never reviewed.
331
+ neurons a message measured, 2,000 a day by default: inside the free allocation; under `dev`, Clef on this computer
332
+ when Ollama has it, free). Emoji and quick lines are never reviewed.
318
333
  - **The owner:** every live room's last minutes in the office with Remove, Mute and Kick (from a line, with their
319
334
  lines taken down), the same in the owner's own game, the reports players make, and Announce (a studio line).
320
335
  - **Speech bubbles:** `net.on('say')` and the port kit's `createBubbles` / `paintBubbles`; Gem Rush, Ember Vale and
@@ -366,7 +381,8 @@ connector (the AI says whether its Homie tools are there; the directory is reach
366
381
  with the studio's own Wrangler, and the account's email verified: a deploy that went through proves it, a
367
382
  deploy refused for it is remembered), Chrome for the checks, ffmpeg, and the optional GitHub, ElevenLabs and
368
383
  fal (and, in Claude Code, the status line); in a studio that sells (shop.json), a Stripe row: whether Stripe's own
369
- agent plugin (its MCP server) is set up for this AI, with `stripe agent setup` as the fix (0.24.3). Each row is green, missing or "do this now", says what it
384
+ agent plugin (its MCP server) is set up for this AI, with `stripe agent setup` as the fix (0.24.3); and in a studio, whether Clef is on this
385
+ computer (Ollama with `clef-flash`, optional, never downloaded without the person's yes) (0.24.4). Each row is green, missing or "do this now", says what it
370
386
  unlocks, and gives the exact fix: a command the AI runs or a page the person taps. It only reads, answers in
371
387
  seconds, and never prints a key, a token or an account's name or id. A studio the Claude app's setup card made
372
388
  (`setup attach`) counts as connected to Cloudflare and GitHub there. `--json` gives the rows.
package/agents/GUIDES.md CHANGED
@@ -10,8 +10,8 @@ Vale (`homie-studio game new <id> --from ember-vale`) is the worked example.
10
10
  - A beginner server whose AI guide seats (always marked "· AI") follow new heroes, lead them to places, take on a
11
11
  quest with them, and pull back when asked.
12
12
  - **Hands** are your game's own bot code, every frame, at the party's skill dial. **The brain** picks a goal every
13
- few seconds: the studio's own Workers AI, the owner's own key, or the owner's Claude in a seat. With none of them
14
- (or over the day's budget) your scripted `decide` plays.
13
+ few seconds: the studio's own Workers AI (Clef), the owner's own key, or the owner's Claude in a seat. With none
14
+ of them (or over the day's budget) your scripted `decide` plays.
15
15
  - The AI never types. It chooses ids from your `agents.json`; your game draws your own words.
16
16
 
17
17
  ## 1. Upgrade
@@ -86,10 +86,17 @@ const agents = useAgents(room.net, vocab as unknown as Vocabulary, {
86
86
 
87
87
  1. `homie-studio servers new <id> "First Steps" --policy beginner --guides 2` (add `--kids` for children).
88
88
  2. `homie-studio agents brain <id> first-steps workers-ai`: the first time is an ASK the owner taps; then
89
- `npm run deploy` once (it binds Workers AI). The free allocation (10,000 neurons a day) gives about six busy
90
- guide-hours; the default budget is 8,000. Or `owner-key` with `agents brain key` on the owner's own computer.
91
- 3. Try it: two browsers on the server's page, walk up to a guide, tap "Help me with ...". In a terminal or the
92
- local MCP, `agent_sit { game: "<id>", server: "first-steps" }` puts your own Claude in a guide's seat.
89
+ `npm run deploy` once (it binds Workers AI). The guides think with Cloudflare's Clef decision model
90
+ (`@cf/cloudflare/clef-flash`, the default since 0.24.4): it answers each decision as typed Choices of your
91
+ goals, values and lines, so it does what an ask asks far more often than a chat model did. About 9 neurons a
92
+ decision: the default budget of 8,000 a day is about 900 decisions (the free allocation is 10,000). Or
93
+ `owner-key` with `agents brain key` on the owner's own computer.
94
+ 3. Try it: `homie-studio agents try <id> --view view.json --ask ask_help:quest=<quest> --from 0` shows what the
95
+ brain would decide in that moment (with `--model` to compare one). Then two browsers on the server's page, walk
96
+ up to a guide, tap "Help me with ...". In a terminal or the local MCP, `agent_sit { game: "<id>", server:
97
+ "first-steps" }` puts your own Claude in a guide's seat; `brain: "local"` lets Clef on your computer decide.
98
+ Under `npm run dev` the guides think with Clef on your own computer when Ollama has `clef-flash` (free; it is
99
+ about 11 GB and only ever downloaded when you say so: `ollama pull clef-flash`).
93
100
 
94
101
  ## 5. Do not
95
102
 
@@ -18,8 +18,10 @@
18
18
  * homie-studio games
19
19
  * homie-studio build [<id>] [--maps] (--maps: also keep each bundle's source map and module sizes in .studio/maps/<id>/,
20
20
  * never in site/dist: what `perf` reads a CPU profile through)
21
- * homie-studio dev [--port 8787] [--remote-ai] (--stop: stop exactly this studio's dev server, nothing else;
22
- * AI guides think scripted here unless --remote-ai: real Workers AI, billed)
21
+ * homie-studio dev [--port 8787] [--remote-ai] [--no-local-ai] (--stop: stop exactly this studio's dev server, nothing else;
22
+ * AI guides, chat review and game decisions think with Clef on this computer
23
+ * when Ollama has clef-flash (free; dev never downloads it), else scripted;
24
+ * --remote-ai: the real Workers AI, billed)
23
25
  * homie-studio check <id> [--url <site>] [--shots <dir>]
24
26
  * homie-studio perf <id> [--url <site>] [--device computer,phone] [--runs 1] [--seconds 15] [--warm 3] [--profile]
25
27
  * [--cpu 4] [--out <dir>] [--max-load 0.8] [--pair <label>]
@@ -121,6 +123,11 @@
121
123
  * time AI guides may talk are ASKED for, like office kick; --budget is
122
124
  * Workers AI neurons a day, or dollars a day for the owner's key)
123
125
  * homie-studio agents brain key [--remove] (the owner's AI key, typed into a page on this computer only)
126
+ * homie-studio agents sit <game> [--server <id>] [--brain local] (a guide's seat from this terminal; --brain local:
127
+ * Clef on this computer decides every few seconds, free; never downloads it)
128
+ * homie-studio agents try <game> --view <file.json> [--ask <ask>[:<arg>=<value>] --from <seat>] [--model <id>]
129
+ * (what the guides' brain would decide in that moment, with no seat taken;
130
+ * spent from the guides' day; --model compares a model before setting it)
124
131
  * homie-studio shop is the studio's shop selling (its own Stripe), and if not, what is missing
125
132
  * homie-studio shop init [--supporter] [--currency usd] [--price 500] [--managed] shop.json and SELLING.md
126
133
  * homie-studio shop check shop.json against the kit's rules (real money, nothing random, the kids rules)
@@ -245,7 +252,8 @@ import { statsKey, statsLink, statsRevoke, statsShare, statsShow } from '../lib/
245
252
  import { playersOwner, playersShow } from '../lib/players.mjs';
246
253
  import { officeAnnounce, officeClose, officeInvite, officeKey, officeKick, officeLaunch, officeLines, officeLink, officeMute, officeRevoke, officeShow } from '../lib/office.mjs';
247
254
  import { chatBudget, chatLines, chatRemove, chatRulesSet, chatShow, chatWords } from '../lib/chat-cli.mjs';
248
- import { agentsBrain, agentsBrainKey, agentsPass, agentsPasses, agentsRevoke, serversClose, serversLevel, serversLines, serversList, serversMember, serversNew, serversSet } from '../lib/servers.mjs';
255
+ import { detectLocalAi, localAiVars } from '../lib/local-ai.mjs';
256
+ import { agentsBrain, agentsBrainKey, agentsPass, agentsPasses, agentsRevoke, agentsTry, serversClose, serversLevel, serversLines, serversList, serversMember, serversNew, serversSet } from '../lib/servers.mjs';
249
257
  import { AgentSeat } from '../lib/agent-seat.mjs';
250
258
  import { shopCheck, shopConnect, shopDisconnect, shopInit, shopLines, shopOrders, shopRefund, shopStatements, shopStatus } from '../lib/shop.mjs';
251
259
  import { catalogLines, catalogPlan } from '../lib/shop-catalog.mjs';
@@ -263,7 +271,7 @@ import { formatHandoff, handoff } from '../lib/handoff.mjs';
263
271
 
264
272
  const argv = process.argv.slice(2);
265
273
  const flags = new Map();
266
- const BOOL_FLAGS = ['off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'supporter', 'managed', 'live', 'send', 'accept-tos'];
274
+ const BOOL_FLAGS = ['off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'supporter', 'managed', 'live', 'send', 'accept-tos', 'quiet', 'no-local-ai'];
267
275
  const positional = [];
268
276
  for (let i = 0; i < argv.length; i++) {
269
277
  const a = argv[i];
@@ -825,8 +833,9 @@ async function main() {
825
833
  if (sub === 'revoke') return agentsRevoke(root, positional[2], { url });
826
834
  if (sub === 'brain' && positional[2] === 'key') return agentsBrainKey(root, { remove: flags.has('remove'), log });
827
835
  if (sub === 'brain') return agentsBrain(root, positional[2], positional[3], positional[4], { url, budget: flags.get('budget') });
828
- if (sub === 'sit') return agentsSit(root, positional[2], { url, server: flags.get('server'), pass: flags.get('pass'), label: flags.get('label') });
829
- return { ok: false, command: 'agents', why: `unknown: agents ${sub ?? ''} (pass, passes, revoke, brain, brain key, sit)` };
836
+ if (sub === 'sit') return agentsSit(root, positional[2], { url, server: flags.get('server'), pass: flags.get('pass'), label: flags.get('label'), brain: flags.get('brain') });
837
+ if (sub === 'try') return agentsTry(root, positional[2], { url, view: flags.get('view'), ask: flags.get('ask'), from: flags.get('from'), model: flags.get('model'), mode: flags.get('mode'), quiet: flags.has('quiet'), avoid: flags.get('avoid'), players: flags.get('players'), readFile: (f) => readFileSync(resolve(f), 'utf8') });
838
+ return { ok: false, command: 'agents', why: `unknown: agents ${sub ?? ''} (pass, passes, revoke, brain, brain key, sit, try)` };
830
839
  }
831
840
  if (cmd === 'media' && sub === 'put') return mediaPut(root, positional[2], flags.get('as'));
832
841
  if (cmd === 'media' && sub === 'move') return mediaMove(root, { paths: positional.slice(2), dryRun: flags.has('dry-run') || flags.has('plan'), verify: flags.has('verify'), log });
@@ -1030,9 +1039,17 @@ async function dev(root) {
1030
1039
  log(`Local site: http://127.0.0.1:${port}/ (each game: http://127.0.0.1:${port}/<id>/play — open it in two browsers)`);
1031
1040
  const ai = devConfig(root, flags.has('remote-ai'));
1032
1041
  if (ai.note) log(ai.note);
1042
+ // The person's own Clef (0.24.4, lib/local-ai.mjs): with Ollama and clef-flash on this computer, the local Worker's
1043
+ // guides, chat review and game decisions think here, free. Never a download: dev only says what one would cost.
1044
+ let localVars = [];
1045
+ if (!flags.has('remote-ai') && !flags.has('no-local-ai')) {
1046
+ const found = await detectLocalAi();
1047
+ localVars = localAiVars(found);
1048
+ log(found.ok ? `AI guides, chat review and game decisions think with ${found.model} on this computer (Ollama ${found.version}): free, nothing sent to Cloudflare.` : found.say);
1049
+ }
1033
1050
  // --remote-ai: Wrangler's --local turns every remote binding off ("not supported"), so a dev with the real Workers AI
1034
1051
  // (the guides' brains, room chat's review) runs without it; everything else stays local all the same.
1035
- const child = spawn(bin, ['dev', ...(flags.has('remote-ai') ? [] : ['--local']), '--ip', '127.0.0.1', '--port', port, ...ai.args], { cwd: workerDir(root), env, stdio: 'inherit' });
1052
+ const child = spawn(bin, ['dev', ...(flags.has('remote-ai') ? [] : ['--local']), '--ip', '127.0.0.1', '--port', port, ...ai.args, ...localVars], { cwd: workerDir(root), env, stdio: 'inherit' });
1036
1053
  mkdirSync(dirname(devFile(root)), { recursive: true });
1037
1054
  writeFileSync(devFile(root), `${JSON.stringify({ pid: process.pid, child: child.pid, port: Number(port), at: new Date().toISOString() })}\n`);
1038
1055
  log(`Stop it with: npx --no-install homie-studio dev --stop (this studio's dev server only)`);
@@ -1063,13 +1080,20 @@ function devConfig(root, remoteAi) {
1063
1080
  const copy = join(base, '.wrangler', 'homie-dev.wrangler.json');
1064
1081
  mkdirSync(dirname(copy), { recursive: true });
1065
1082
  writeFileSync(copy, `${JSON.stringify(json, null, 2)}\n`);
1066
- return { args: ['--config', copy, '--persist-to', join(base, '.wrangler', 'state')], note: remoteAi ? 'AI guides think with Workers AI (remote, billed to the signed-in account).' : 'AI guides think scripted here (no Workers AI under dev; --remote-ai for the real one).' };
1083
+ return { args: ['--config', copy, '--persist-to', join(base, '.wrangler', 'state')], note: remoteAi ? 'AI guides think with Workers AI (remote, billed to the signed-in account).' : 'No Workers AI under dev (--remote-ai for the real one, billed): AI guides think with Clef on this computer when Ollama has it, else scripted.' };
1067
1084
  }
1068
1085
 
1069
1086
  /** `agents sit`: an AI guide's seat from this terminal, for a demo or a test of a vocabulary. Lines on stdin act. */
1070
- async function agentsSit(root, game, { url, server, pass, label }) {
1087
+ async function agentsSit(root, game, { url, server, pass, label, brain }) {
1071
1088
  const site = url ?? siteUrl(root);
1072
- const seat = new AgentSeat({ site, game, server: server ?? null, pass: pass ?? null, label: label ?? 'Claude', root });
1089
+ // --brain local: Clef on this computer decides (lib/local-ai.mjs); never a download, only what one would cost.
1090
+ let local = null;
1091
+ if (brain === 'local') {
1092
+ local = await detectLocalAi();
1093
+ if (!local.ok) return { ok: false, command: 'agents sit', why: local.say };
1094
+ log(`This seat thinks with ${local.model} on this computer (Ollama ${local.version}) every few seconds. Type stand to leave.`);
1095
+ } else if (brain !== undefined) return { ok: false, command: 'agents sit', why: '--brain is local (Clef on this computer), or leave it out to choose yourself' };
1096
+ const seat = new AgentSeat({ site, game, server: server ?? null, pass: pass ?? null, label: label ?? (local ? 'Clef' : 'Claude'), root, brain: local ? 'local' : null, local });
1073
1097
  const r = await seat.sit();
1074
1098
  if (!r.ok) return { ok: false, command: 'agents sit', why: r.why };
1075
1099
  log(`Seated as ${r.name} in ${r.room} (seat ${r.seat}). Type: look | do <goal> {"arg":…} | say <line> {"arg":…} with a goal | stand`);
package/chat/CHAT.md CHANGED
@@ -14,9 +14,12 @@ studio's owner is responsible for, read [OWNERS.md](OWNERS.md).
14
14
 
15
15
  - **The Chat pill** on the play page, beside the room button: the room's last minutes, the
16
16
  reactions, the quick lines, typing where allowed, Report on a line, and two switches ("Show my
17
- messages over my character", "Show chat on this screen").
17
+ messages over my character", "Show chat on this screen"). On a phone it is a round icon, as the
18
+ room button is, with the count of new lines on its corner; on a computer it says "Chat" unless
19
+ the word would reach the middle of the top edge, where games keep a clock or a title.
18
20
  - **The float:** every reaction rises up every screen in the room.
19
- - **The ticker:** a new line shows for a moment where the play page's status chip sits.
21
+ - **The strip:** a new line shows for a moment where the play page's status chip sits (the bottom
22
+ left), or where the game says (below).
20
23
  - **The big screen** (`/<game>/tv`) shows the room's lines in a corner; the **watch page** has a
21
24
  Chat button; **homie.rocks** shows the room's chat on its card when the studio allows it.
22
25
 
@@ -49,9 +52,37 @@ studio's owner is responsible for, read [OWNERS.md](OWNERS.md).
49
52
  | `block`, `allow` | the studio's own words to hold (`word*` inside words) or to let through | none |
50
53
 
51
54
  `"chat": false` turns it off. A kids server, and every beginner server, keeps chat to emoji and
52
- quick lines whatever the game says. Where new lines show for a moment: game.json
53
- `"screen": { "chat": "bottom-right" }` (any corner, `top-center` or `bottom-center`), or `false`
54
- for only the pill's count.
55
+ quick lines whatever the game says.
56
+
57
+ **Where it sits on the screen, in game.json `"screen"`**, so a game with a busy HUD keeps typed
58
+ chat without anything covering its clock, its title or its controls. The Chat pill sits in the
59
+ room button's band, so `"screen": { "share": … }` places both (`"label": false` keeps both round
60
+ icons). `"screen": { "chat": … }` places the strip of new lines and the big screen's corner:
61
+
62
+ ```json
63
+ "screen": {
64
+ "share": { "phone": { "at": "top-left", "label": false } },
65
+ "chat": {
66
+ "desk": { "at": "top-left", "y": 130 },
67
+ "phone": { "lines": "sheet-only" },
68
+ "tv": { "at": "top-right" }
69
+ }
70
+ }
71
+ ```
72
+
73
+ | Field | | Default |
74
+ |---|---|---|
75
+ | `at` | `top-left`, `top-center`, `top-right`, `bottom-left`, `bottom-center` or `bottom-right` | the bottom left (the bottom right when the room button is at the bottom left) |
76
+ | `x`, `y` | move it in from its side and its edge, in CSS pixels (0 to 600; in the middle of an edge, `x` moves it either way). Sharing the room button's place, it also sits past the button | 0 |
77
+ | `lines` | `strip` (new lines show for a moment), or `sheet-only` (they wait in the Chat sheet and the pill counts them) | `strip` |
78
+ | `desk`, `phone`, `sideways`, `tv` | a computer, a phone held upright, a phone turned sideways (else as `phone`), the big screen: each changes only what it names | |
79
+
80
+ A plain string (`"chat": "bottom-right"`) is a place for every device, and `false` is
81
+ `"lines": "sheet-only"` for every device. The big screen has no sheet: it takes a corner only
82
+ (the top level's when that is a corner, else the one its join card leaves free) and keeps its
83
+ lines unless its own `tv` entry says `"sheet-only"`, which leaves it the float. A wrong field is
84
+ the default on the page, and `homie-studio build` says which. Look at the play page on a phone
85
+ and a computer while a round is on and somebody says something, and at `/<id>/tv`.
55
86
 
56
87
  **Speech bubbles over characters**, with the port kit (the starters draw them):
57
88
 
@@ -90,6 +121,8 @@ accuracy numbers for it, and the message it reads is a player's, so a player can
90
121
  (less of a risk than with a model that writes, since it only scores fixed answers). That is why
91
122
  **the word list always runs first and decides alone** whenever the review cannot (no binding, the
92
123
  day's budget used, an error, a slow answer). `HOMIE_CHAT_MODEL` switches to Clef or Llama Guard.
124
+ Under `homie-studio dev` with no Workers AI binding, the same question goes to Clef on the person's
125
+ own computer when Ollama has `clef-flash` (0.24.4): free, nothing sent to Cloudflare.
93
126
  Firewall for AI (now "AI Security for Apps") was ruled out: it reads HTTP requests at the zone, not
94
127
  messages inside a Durable Object, and its detections are an Enterprise add-on.
95
128
 
@@ -8,8 +8,13 @@
8
8
  *
9
9
  * The pass: one the owner gave (`hap_…`), or, from a studio folder, a one-day guide pass minted with the owner's office
10
10
  * key and revoked when the AI stands. The secret stays in this process; it is never printed or returned.
11
+ *
12
+ * BRAIN 'local' (0.24.4): instead of an AI's turns, Cloudflare's Clef decision model on this computer (Ollama with
13
+ * clef-flash, lib/local-ai.mjs) decides every few seconds, the way a house guide does (worker/agents.mjs think: the same
14
+ * questions, the same fixed rules), for free and with nothing sent to Cloudflare. agent_do still overrides it.
11
15
  */
12
- import { parseDecision, renderLine, vocabularyOf } from '../worker/brain.mjs';
16
+ import { clefDecide, parseDecision, renderLine, vocabularyOf } from '../worker/brain.mjs';
17
+ import { think } from '../worker/agents.mjs';
13
18
  import { withKey } from './office.mjs';
14
19
 
15
20
  const GAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
@@ -26,7 +31,7 @@ export class AgentSeat {
26
31
  * @param {string} [o.pass] an agent pass (hap_…); else one is minted from `root` with the office key
27
32
  * @param {string} [o.root] the studio folder (for a pass of its own)
28
33
  */
29
- constructor({ site, game, server = null, pass = null, label = 'Claude', root = null, WebSocketImpl = globalThis.WebSocket, fetchImpl = globalThis.fetch }) {
34
+ constructor({ site, game, server = null, pass = null, label = 'Claude', root = null, WebSocketImpl = globalThis.WebSocket, fetchImpl = globalThis.fetch, brain = null, local = null }) {
30
35
  this.site = String(site ?? '').replace(/\/+$/, '');
31
36
  this.game = game;
32
37
  this.server = server;
@@ -52,6 +57,10 @@ export class AgentSeat {
52
57
  this.errors = [];
53
58
  this.vocab = null;
54
59
  this.ping = null;
60
+ /** brain 'local': Clef on this computer ({ base, model }) decides; its last decisions, and who said no thanks. */
61
+ this.brain = brain === 'local' && local ? 'local' : null;
62
+ this.local = local;
63
+ this.auto = { timer: null, busy: false, at: 0, decisions: [], avoid: new Map(), said: [], goalState: null };
55
64
  }
56
65
 
57
66
  async sit() {
@@ -101,9 +110,56 @@ export class AgentSeat {
101
110
  this.ping = setInterval(() => { try { ws.send(JSON.stringify({ t: 'ping', c: Date.now() })); } catch { /* closed */ } }, 2000);
102
111
  // The host shows a guide the game every 2 s: wait for the first look (up to 6 s).
103
112
  for (let i = 0; i < 30 && !this.view && !this.closed; i += 1) await wait(200);
113
+ if (this.brain === 'local') this.autopilot();
104
114
  return { ok: true, seat: this.seat, name: this.name, room: this.room, server: this.server, ...this.look() };
105
115
  }
106
116
 
117
+ /**
118
+ * BRAIN 'local': a decision on a new ask (at once), when the goal in force ends, else every 12 s while the room has a
119
+ * view; never more than one at a time, and do() keeps the 3 s pace and the 8 s quiet. No thanks holds that player off
120
+ * for 10 minutes.
121
+ */
122
+ autopilot() {
123
+ const tick = async () => {
124
+ if (this.closed) { this.stopAuto(); return; }
125
+ const now = Date.now();
126
+ const a = this.auto;
127
+ const asks = this.asks.filter((x) => now - x.at < 30_000);
128
+ const state = this.view?.goal && typeof this.view.goal === 'object' ? this.view.goal.state ?? null : null;
129
+ const ended = (state === 'done' || state === 'failed') && a.goalState !== state;
130
+ a.goalState = state;
131
+ if (a.busy || !this.view || !(asks.length || ended || now - a.at >= 12_000)) return;
132
+ a.busy = true;
133
+ a.at = now;
134
+ for (const [s, until] of a.avoid) if (until <= now) a.avoid.delete(s);
135
+ for (const x of asks) if (this.vocab?.asks?.[x.k]?.leave) a.avoid.set(x.from, now + 10 * 60_000);
136
+ try {
137
+ const party = Array.isArray(this.view.party) ? this.view.party.map((p) => p?.seat).filter(Number.isInteger) : [];
138
+ const ctx = {
139
+ view: this.view, me: { seat: this.seat }, goal: this.view.goal ?? null, asks, party: this.party.filter((p) => now - p.at < 60_000),
140
+ speech: this.policy?.speech ?? 'lines', kids: this.policy?.kids === true, quiet: !this.talking() || now - this.sayAt < 8000,
141
+ players: party, avoid: [...a.avoid.keys()], said: a.said.map((x) => ({ ...x, ago: now - x.at })),
142
+ };
143
+ const local = this.local;
144
+ const t = await think(this.vocab, { mode: 'local', brain: { engine: 'local', run: (args) => clefDecide({}, { vocab: args.vocab, ctx: args.ctx, model: local.model, ms: 8000, local, fetch: this.fetch }) }, ctx });
145
+ const d = t.decision;
146
+ let sent = null;
147
+ if (d?.goal && !this.closed) {
148
+ const say = d.say && this.talking() && Date.now() - this.sayAt >= 8000 ? d.say : null;
149
+ sent = await this.do({ goal: d.goal, args: d.args, say, sayArgs: say ? d.sayArgs : {} });
150
+ if (sent.ok && say) a.said = [...a.said, { line: say, args: d.sayArgs, at: Date.now() }].slice(-4);
151
+ }
152
+ a.decisions = [...a.decisions, { at: new Date(now).toISOString(), provider: t.provider, goal: d?.goal ?? null, args: d?.args ?? null, say: d?.say ?? null, ms: t.ms, ...(t.why ? { why: t.why } : {}), ...(sent && !sent.ok ? { refused: sent.why } : {}) }].slice(-20);
153
+ } catch (error) {
154
+ a.decisions = [...a.decisions, { at: new Date(now).toISOString(), provider: 'none', why: String(error?.message ?? error).slice(0, 120) }].slice(-20);
155
+ } finally { a.busy = false; }
156
+ };
157
+ this.auto.timer = setInterval(() => { void tick(); }, 1000);
158
+ void tick();
159
+ }
160
+
161
+ stopAuto() { if (this.auto.timer) clearInterval(this.auto.timer); this.auto.timer = null; }
162
+
107
163
  onFrame(m) {
108
164
  switch (m.t) {
109
165
  case 'welcome': this.seat = m.seat; this.name = m.name; this.policy = m.policy ?? null; return;
@@ -135,6 +191,7 @@ export class AgentSeat {
135
191
  asks: this.asks.filter((a) => now - a.at < 60_000).map((a) => ({ ask: a.k, args: a.args, seat: a.from, text: v ? renderLine(v, a.k, a.args, { kind: 'asks', nameOf: (s) => `seat ${s}` }) : null, secondsAgo: Math.round((now - a.at) / 1000) })),
136
192
  party: this.party.filter((p) => now - p.at < 60_000).map((p) => ({ seat: p.seat, line: p.line, args: p.args })),
137
193
  lastDo: this.lastDo,
194
+ ...(this.brain === 'local' ? { brain: { local: this.local.model, decisions: this.auto.decisions.slice(-5) } } : {}),
138
195
  choices: v ? { goals: describe(v.goals), lines: describe(v.lines) } : null,
139
196
  errors: this.errors.slice(-3),
140
197
  };
@@ -162,6 +219,7 @@ export class AgentSeat {
162
219
  talking() { return Boolean(this.policy && ['workers-ai', 'owner-key'].includes(this.policy.brain) && this.policy.speech !== 'off'); }
163
220
 
164
221
  close() {
222
+ this.stopAuto();
165
223
  if (this.ping) clearInterval(this.ping);
166
224
  this.ping = null;
167
225
  if (this.ws) { try { this.ws.send(JSON.stringify({ t: 'bye' })); } catch { /* closed */ } try { this.ws.close(1000, 'bye'); } catch { /* closed */ } }
@@ -6,14 +6,14 @@
6
6
  * doctor asks once.
7
7
  *
8
8
  * The check is ONE tiny call to the model through Cloudflare's REST API (`POST /accounts/:id/ai/run/:model`, a
9
- * one-word prompt, max_tokens 1: about 0.1 of the 10,000 free neurons a day), with the studio's own Wrangler login
9
+ * one-word prompt, max_tokens 1, or for a Clef decision model one yes/no question: a few of the 10,000 free neurons a day), with the studio's own Wrangler login
10
10
  * (`wrangler auth token`, held in this process only) or CLOUDFLARE_API_TOKEN. It never prints the token, the account's
11
11
  * id or its name. A call that cannot be made (not signed in, several accounts and none named) says so and spends
12
12
  * nothing.
13
13
  */
14
14
  import { existsSync, readFileSync } from 'node:fs';
15
15
  import { join } from 'node:path';
16
- import { DEFAULT_MODEL } from '../worker/brain.mjs';
16
+ import { CLEF, DEFAULT_MODEL } from '../worker/brain.mjs';
17
17
  import { configPath, workerDir } from './studio.mjs';
18
18
 
19
19
  const MODELS = 'https://developers.cloudflare.com/workers-ai/models/';
@@ -96,7 +96,8 @@ export async function probeModel({ model, accountId, headers, fetchFn = globalTh
96
96
  const res = await fetchFn(`${String(base).replace(/\/+$/, '')}/accounts/${encodeURIComponent(accountId)}/ai/run/${model}`, {
97
97
  method: 'POST',
98
98
  headers: { ...headers, 'content-type': 'application/json', accept: 'application/json', 'user-agent': 'homie-studio-doctor' },
99
- body: JSON.stringify({ messages: [{ role: 'user', content: 'Say OK.' }], max_tokens: 1 }),
99
+ // A decision model (Clef) takes a state and a question, not a chat message: one yes/no, a few hundred tokens.
100
+ body: JSON.stringify(CLEF.models[model] ? { model: CLEF.models[model], state: 'OK', questions: { ok: { type: 'noul', instructions: 'Does the state say OK?' } } } : { messages: [{ role: 'user', content: 'Say OK.' }], max_tokens: 1 }),
100
101
  signal: AbortSignal.timeout(ms),
101
102
  });
102
103
  return { status: res.status, body: await res.json().catch(() => null) };
@@ -122,7 +123,7 @@ export async function workersAiRow({ root, studio, env = process.env, exec, fetc
122
123
  const where = set ? 'HOMIE_BRAIN_MODEL in wrangler.jsonc' : 'the toolkit\'s default';
123
124
  const pick = (lead) => ({
124
125
  who: 'ai', open: MODELS,
125
- say: `${lead}Pick a model the account can run: set HOMIE_BRAIN_MODEL in wrangler.jsonc "vars" to a text-generation model from ${MODELS} that is not marked Workers Paid${set && model !== DEFAULT_MODEL ? ` (or remove it for the default, ${DEFAULT_MODEL})` : ''}, run \`${cli} doctor\` again, then \`npm run deploy\`. Until then the guides answer from the game's script.`,
126
+ say: `${lead}Pick a model the account can run: set HOMIE_BRAIN_MODEL in wrangler.jsonc "vars" to a model from ${MODELS} that is not marked Workers Paid (Clef, @cf/cloudflare/clef-flash, or a text-generation model)${set && model !== DEFAULT_MODEL ? ` (or remove it for the default, ${DEFAULT_MODEL})` : ''}, run \`${cli} doctor\` again, then \`npm run deploy\`. Until then the guides answer from the game's script.`,
126
127
  });
127
128
  const money = ' Moving the account to Workers Paid ($5 a month) also works, but that is the person\'s money: ask them, never decide it.';
128
129
  if (!MODEL_ID.test(model)) return { ...row, state: 'act', detail: `${where} is "${model.slice(0, 80)}", which is not a Workers AI model id (@cf/<maker>/<model>)`, fix: pick('') };
@@ -145,7 +146,7 @@ export async function workersAiRow({ root, studio, env = process.env, exec, fetc
145
146
  case 'missing': return { ...row, state: 'act', detail: `Cloudflare has no model ${model} (renamed or retired)${said}`, fix: pick('') };
146
147
  case 'not-allowed': return { ...row, state: 'act', detail: `this account may not use ${model} (a private model)${said}`, fix: pick('') };
147
148
  case 'agreement': return { ...row, state: 'act', detail: `${model} asks for its maker's licence to be agreed once before it is used${said}`, fix: pick('Agreeing to a licence is the person\'s to do, never yours; or simply: ') };
148
- case 'input': return { ...row, state: 'act', detail: `${model} did not take a chat message: the guides need a text-generation model${said}`, fix: pick('') };
149
+ case 'input': return { ...row, state: 'act', detail: `${model} did not take a chat message: the guides need Clef or a text-generation model${said}`, fix: pick('') };
149
150
  case 'allowance': return { ...row, state: 'later', detail: `today's free Workers AI allowance (10,000 neurons for the account) is used up, so ${model} could not be checked; the guides answer from the script until 00:00 UTC`, fix: { who: 'person', say: `Nothing to do now: run doctor again after 00:00 UTC. The studio's daily budget (\`${cli} agents brain <game> <server> workers-ai --budget <neurons>\`) keeps the guides under the allowance.` } };
150
151
  case 'busy': return { ...row, state: 'unknown', detail: `Workers AI was busy just now (status ${answer.status})${said}; run doctor again in a minute`, fix: null };
151
152
  case 'auth': return { ...row, state: 'unknown', detail: `Cloudflare did not let this sign-in run Workers AI (status ${answer.status})${said}`, fix: { who: 'ai', run: 'npx wrangler login', say: 'Sign in to Cloudflare again (the login includes Workers AI), then run doctor again.' } };
package/lib/build.mjs CHANGED
@@ -47,6 +47,7 @@ import { STUDIO_VERSION } from './version.mjs';
47
47
  import { licenseOf, remixRow } from '../worker/license.mjs';
48
48
  import { SERVER_LIMITS, serverOf } from '../worker/servers.mjs';
49
49
  import { chatProblems } from '../worker/chat.mjs';
50
+ import { screenChatProblems } from '../worker/chat-page.mjs';
50
51
  import { vocabularyOf } from '../worker/brain.mjs';
51
52
  import { shopForBuild } from './shop.mjs';
52
53
  import { audienceOf } from '../worker/shop-rules.mjs';
@@ -321,6 +322,10 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
321
322
  // Room chat (NETPLAY.md section 19): game.json "chat", checked here (its quick lines pass the chat floor too).
322
323
  const chatBad = chatProblems(g.chat);
323
324
  if (chatBad.length) throw new Error(`games/${g.id}/game.json: ${chatBad.join('; ')}`);
325
+ // A game's own decisions (NETPLAY.md section 20): game.json "decide" is true or false (the Worker answers net.decide only then).
326
+ if (g.decide !== undefined && typeof g.decide !== 'boolean') throw new Error(`games/${g.id}/game.json: "decide" is true (the host may ask the studio's decision model, within the AI brains' day) or false`);
327
+ // Where chat sits on the screen (game.json "screen": { "chat" }, 0.24.5): a wrong field is the default on the page.
328
+ for (const p of screenChatProblems(g.screen?.chat)) log(`warning: games/${g.id}/game.json: ${p}; the play page uses the default there`);
324
329
  // The game's own source, for other studios to remix (game.json "share": { "source": false } keeps it private).
325
330
  if (g.share?.source !== false) writeFileSync(join(out, 'source.json'), `${JSON.stringify(sourceOf(g.dir, g.id, { studio: studio.name ?? null, game: g.name ?? g.id, license: g.license }))}\n`);
326
331
  // Its assets' licences, and the address and SHA-256 of each one a remix may carry (`game remix` fetches them).
@@ -368,6 +373,7 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
368
373
  // game.json `"chat"` (0.23.0, NETPLAY.md section 19): the room chat's defaults for this game (the owner's office
369
374
  // overrides them), or false for none.
370
375
  ...(g.chat === false ? { chat: false } : g.chat && typeof g.chat === 'object' ? { chat: g.chat } : {}),
376
+ ...(g.decide === true ? { decide: true } : {}),
371
377
  // Servers (0.16.0): the seeds game.json ships, the netplay revision its build speaks (the office warns when it
372
378
  // predates servers), and game.json "agents": { "vote": "game" | false } (the game draws its own vote card, or none).
373
379
  ...(seeds.length ? { servers: seeds } : {}),
@@ -282,6 +282,8 @@ export function needsWorkersAi(root, w, db) {
282
282
  // Room chat (0.23.0): a game whose players may type has its messages reviewed by the studio's own Workers AI
283
283
  // (Cloudflare's Clef decision model, within a day's budget of the free allocation) unless its game.json says no.
284
284
  if ((cat.games ?? []).some((g) => g.chat !== false && (g.chat?.mode ?? 'text') === 'text' && g.chat?.ai !== false)) return true;
285
+ // A game's own decisions (0.24.4): a game whose game.json says "decide": true asks the same decision model.
286
+ if ((cat.games ?? []).some((g) => g.decide === true)) return true;
285
287
  } catch { /* not built */ }
286
288
  const r = w(['d1', 'execute', db, '--remote', '--json', '--command', "SELECT COUNT(*) AS n FROM servers WHERE brain = 'workers-ai' AND state = 'open'"]);
287
289
  if (r.code !== 0) return false;
package/lib/doctor.mjs CHANGED
@@ -19,6 +19,7 @@
19
19
  * Stripe for selling only in a studio with shop.json (0.24.3): Stripe's own agent plugin (its MCP server
20
20
  * and skills) for this AI, set up when the studio starts selling; the shop's key itself
21
21
  * goes in through `homie-studio shop connect`, never here
22
+ * Clef locally optional Ollama with clef-flash: AI under dev for free (never downloaded without a yes)
22
23
  * status line optional (Claude Code only) the build's progress under the prompt
23
24
  *
24
25
  * It is safe at any time, inside a studio or before one exists: it only reads (local files, `--version` of a few
@@ -31,6 +32,7 @@ import { spawn } from 'node:child_process';
31
32
  import { existsSync, readFileSync } from 'node:fs';
32
33
  import { join } from 'node:path';
33
34
  import { workersAiRow } from './brain-probe.mjs';
35
+ import { detectLocalAi } from './local-ai.mjs';
34
36
  import { findChrome } from './chrome.mjs';
35
37
  import { whyFailed } from './net.mjs';
36
38
  import { isOurs } from './statusline.mjs';
@@ -228,6 +230,18 @@ export async function setupStatus({
228
230
  });
229
231
  }
230
232
 
233
+ // Clef on this computer (0.24.4, lib/local-ai.mjs): Ollama with clef-flash. Optional, and never downloaded here: the
234
+ // person says yes to the size first. In a studio only (it serves `dev`).
235
+ if (root) {
236
+ const found = await detectLocalAi({ env, fetchFn });
237
+ rows.push({
238
+ id: 'local-ai', label: 'Clef on this computer', need: 'optional', state: found.ok ? 'ok' : 'optional',
239
+ detail: found.ok ? `${found.model} on Ollama ${found.version}` : found.why === 'no-model' ? `Ollama ${found.version} is here, without clef-flash` : found.why === 'old-ollama' ? `Ollama ${found.version} is too old for Clef (0.35.1 or later)` : 'no Ollama running here',
240
+ unlocks: 'AI guides, chat review and a game\'s own decisions under `dev` with no Cloudflare and no cost, and a guide seat that thinks on this computer (agents sit --brain local)',
241
+ fix: found.ok ? null : { who: 'person', say: found.say, ...(found.why === 'no-ollama' ? { open: 'https://ollama.com/download' } : {}) },
242
+ });
243
+ }
244
+
231
245
  // GitHub.
232
246
  rows.push({
233
247
  id: 'github', label: 'GitHub', need: 'optional', state: gh.signedIn || gh.onGitHub ? 'ok' : 'optional',