@homie-rocks/studio 0.24.3 → 0.24.4

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,36 @@ 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.4] - 2026-10-03
21
+
22
+ **Plugin 0.25.4** · [#37](https://github.com/homie-rocks/homie/pull/37)
23
+
24
+ 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.
25
+
26
+ ### Added
27
+
28
+ - **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.
29
+ - `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.
30
+ - **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.
31
+ - **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.
32
+ - 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.
33
+ - 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.
34
+
35
+ ### Changed
36
+
37
+ - 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.
38
+ - 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.
39
+ - 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.
40
+ - The doctor's Workers AI check asks Clef one yes/no question (it does not take a chat message).
41
+
42
+ ### Upgrade notes
43
+
44
+ - `npm run deploy` after upgrading: guides on Workers AI switch to Clef at their next decision. Nothing else to do.
45
+ - 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`.
46
+
20
47
  ## [0.24.3] - 2026-10-03
21
48
 
22
- **Plugin 0.25.3** · [#36](https://github.com/homie-rocks/homie/pull/36)
49
+ **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
50
 
24
51
  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
52
 
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
 
@@ -314,7 +327,8 @@ npx homie-studio chat words # the bu
314
327
  - **Moderation, in the studio's own Worker, before anyone sees a typed line:** a built-in word list and patterns
315
328
  (slurs, sexual words, self-harm, personal questions and contact details, links, swears, your own words), then
316
329
  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.
330
+ neurons a message measured, 2,000 a day by default: inside the free allocation; under `dev`, Clef on this computer
331
+ when Ollama has it, free). Emoji and quick lines are never reviewed.
318
332
  - **The owner:** every live room's last minutes in the office with Remove, Mute and Kick (from a line, with their
319
333
  lines taken down), the same in the owner's own game, the reports players make, and Announce (a studio line).
320
334
  - **Speech bubbles:** `net.on('say')` and the port kit's `createBubbles` / `paintBubbles`; Gem Rush, Ember Vale and
@@ -366,7 +380,8 @@ connector (the AI says whether its Homie tools are there; the directory is reach
366
380
  with the studio's own Wrangler, and the account's email verified: a deploy that went through proves it, a
367
381
  deploy refused for it is remembered), Chrome for the checks, ffmpeg, and the optional GitHub, ElevenLabs and
368
382
  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
383
+ 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
384
+ 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
385
  unlocks, and gives the exact fix: a command the AI runs or a page the person taps. It only reads, answers in
371
386
  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
387
  (`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
@@ -90,6 +90,8 @@ accuracy numbers for it, and the message it reads is a player's, so a player can
90
90
  (less of a risk than with a model that writes, since it only scores fixed answers). That is why
91
91
  **the word list always runs first and decides alone** whenever the review cannot (no binding, the
92
92
  day's budget used, an error, a slow answer). `HOMIE_CHAT_MODEL` switches to Clef or Llama Guard.
93
+ Under `homie-studio dev` with no Workers AI binding, the same question goes to Clef on the person's
94
+ own computer when Ollama has `clef-flash` (0.24.4): free, nothing sent to Cloudflare.
93
95
  Firewall for AI (now "AI Security for Apps") was ruled out: it reads HTTP requests at the zone, not
94
96
  messages inside a Durable Object, and its detections are an Enterprise add-on.
95
97
 
@@ -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
@@ -321,6 +321,8 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
321
321
  // Room chat (NETPLAY.md section 19): game.json "chat", checked here (its quick lines pass the chat floor too).
322
322
  const chatBad = chatProblems(g.chat);
323
323
  if (chatBad.length) throw new Error(`games/${g.id}/game.json: ${chatBad.join('; ')}`);
324
+ // A game's own decisions (NETPLAY.md section 20): game.json "decide" is true or false (the Worker answers net.decide only then).
325
+ 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`);
324
326
  // The game's own source, for other studios to remix (game.json "share": { "source": false } keeps it private).
325
327
  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
328
  // Its assets' licences, and the address and SHA-256 of each one a remix may carry (`game remix` fetches them).
@@ -368,6 +370,7 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
368
370
  // game.json `"chat"` (0.23.0, NETPLAY.md section 19): the room chat's defaults for this game (the owner's office
369
371
  // overrides them), or false for none.
370
372
  ...(g.chat === false ? { chat: false } : g.chat && typeof g.chat === 'object' ? { chat: g.chat } : {}),
373
+ ...(g.decide === true ? { decide: true } : {}),
371
374
  // Servers (0.16.0): the seeds game.json ships, the netplay revision its build speaks (the office warns when it
372
375
  // predates servers), and game.json "agents": { "vote": "game" | false } (the game draws its own vote card, or none).
373
376
  ...(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',
@@ -0,0 +1,66 @@
1
+ /**
2
+ * THE PERSON'S OWN AI (@homie-rocks/studio 0.24.4): Cloudflare's Clef decision model on this computer, through Ollama.
3
+ * The same typed questions the studio's Workers AI answers, at /v1/systemone, free and offline:
4
+ *
5
+ * homie-studio dev finds it and hands it to the local Worker (HOMIE_LOCAL_AI), so AI guides, room chat's review
6
+ * and a game's own decisions (net.decide) need no Cloudflare while building
7
+ * agents sit --brain local / the MCP's agent_sit { brain: "local" }
8
+ * a guide's seat that thinks on this computer every few seconds, with no AI turn per decision
9
+ * homie-studio doctor says what is there, and what one sentence gets the rest
10
+ *
11
+ * It never downloads a model. `ollama pull clef-flash` is about 11 GB (clef, the 27B, about 18 GB): the person says yes
12
+ * first, every time (the studio skill asks and names the size). It needs Ollama 0.35.1 or later (the first with Clef).
13
+ */
14
+
15
+ export const OLLAMA_BASE = 'http://127.0.0.1:11434';
16
+ export const OLLAMA_MIN = '0.35.1';
17
+ /** The models Homie uses, smallest first, and what each costs the disk (ollama.com/library, 2026-10-02). */
18
+ export const LOCAL_MODELS = Object.freeze([
19
+ Object.freeze({ name: 'clef-flash', size: 'about 11 GB', params: '9B' }),
20
+ Object.freeze({ name: 'clef', size: 'about 18 GB', params: '27B' }),
21
+ ]);
22
+
23
+ const newer = (a, b) => {
24
+ const pa = String(a).split(/[.-]/).map((x) => parseInt(x, 10) || 0);
25
+ const pb = String(b).split(/[.-]/).map((x) => parseInt(x, 10) || 0);
26
+ for (let i = 0; i < 3; i += 1) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0);
27
+ return true;
28
+ };
29
+
30
+ /** Only a loopback address: Homie never sends a game's state to another machine's Ollama. */
31
+ export const loopbackBase = (raw) => {
32
+ const s = String(raw ?? '').trim().replace(/\/+$/, '');
33
+ return /^http:\/\/(?:127\.0\.0\.1|localhost|\[::1\]):\d{2,5}$/.test(s) ? s : null;
34
+ };
35
+
36
+ /**
37
+ * Is Clef on this computer? `{ ok, base, model, version }`, or `{ ok: false, why, say, version?, base }` with why one of
38
+ * no-ollama, old-ollama, no-model. `HOMIE_OLLAMA` names another loopback port. Two quick local requests, nothing else.
39
+ */
40
+ export async function detectLocalAi({ env = process.env, fetchFn = globalThis.fetch, ms = 1500 } = {}) {
41
+ const base = loopbackBase(env.HOMIE_OLLAMA) ?? OLLAMA_BASE;
42
+ const get = async (path) => {
43
+ const res = await fetchFn(`${base}${path}`, { signal: AbortSignal.timeout(ms) });
44
+ if (!res.ok) throw new Error(`${path} answered ${res.status}`);
45
+ return res.json();
46
+ };
47
+ let version = null;
48
+ try { version = String((await get('/api/version'))?.version ?? ''); } catch {
49
+ return { ok: false, base, why: 'no-ollama', say: `Ollama is not running on this computer (${base}). With it and clef-flash, AI guides, chat review and game decisions run here for free while building; without it they play from the game's script under dev.` };
50
+ }
51
+ if (!newer(version, OLLAMA_MIN)) return { ok: false, base, version, why: 'old-ollama', say: `Ollama ${version} is here; Clef needs ${OLLAMA_MIN} or later. Updating Ollama is the person's to do (its own app updates itself, or ollama.com/download).` };
52
+ let names = [];
53
+ try { names = ((await get('/api/tags'))?.models ?? []).map((m) => String(m?.name ?? m?.model ?? '')).filter(Boolean); } catch { names = []; }
54
+ for (const want of LOCAL_MODELS) {
55
+ const hit = names.find((n) => n === want.name || n.startsWith(`${want.name}:`));
56
+ if (hit) return { ok: true, base, version, model: hit };
57
+ }
58
+ const flash = LOCAL_MODELS[0];
59
+ return {
60
+ ok: false, base, version, why: 'no-model', models: names.length,
61
+ say: `Ollama ${version} is here without Clef. Downloading it is the person's choice: ask first. \`ollama pull ${flash.name}\` downloads ${flash.size} (the ${flash.params} model; ${LOCAL_MODELS[1].name} is ${LOCAL_MODELS[1].size}). Then run dev again.`,
62
+ };
63
+ }
64
+
65
+ /** The Worker variables that hand a found model to `wrangler dev` (`--var` pairs), or none. */
66
+ export const localAiVars = (found) => (found?.ok ? ['--var', `HOMIE_LOCAL_AI:${found.base}`, '--var', `HOMIE_LOCAL_AI_MODEL:${found.model}`] : []);
package/lib/mcp-tools.mjs CHANGED
@@ -33,6 +33,7 @@ import { STUDIO_VERSION } from './version.mjs';
33
33
  import { compareVersions, whatsNew, whatsNewLines } from './changelog.mjs';
34
34
  import { pinnedVersion } from './upgrade.mjs';
35
35
  import { AgentSeat } from './agent-seat.mjs';
36
+ import { detectLocalAi } from './local-ai.mjs';
36
37
  import { LAB_PORT, runningLab } from './lab.mjs';
37
38
  import { pictureFor } from './pictures.mjs';
38
39
  import { ART_UI, artToolDefs } from './art-tools.mjs';
@@ -1050,17 +1051,24 @@ export function toolDefs(ctx, avail = {}) {
1050
1051
  {
1051
1052
  name: 'agent_sit', title: 'Sit in a game as an AI guide',
1052
1053
  description: 'Take a guide\'s seat in a live room of one of the studio\'s games as an AI ("Claude · AI": always marked AI, never on a humans-only server, only in a room with people in it). The game\'s own bot code moves the body every frame; you choose its goal and, when the server lets its AI talk, one of the game\'s own lines (agents.json; never free text). Returns your seat, what you see (view), the asks players made of you and your choices. Then agent_look and agent_do every 20 to 40 seconds, and agent_stand when done. Uses the site running here (preview_run) or the live site; with no pass, makes a one-day guide pass with the owner\'s office key and revokes it when you stand.',
1053
- inputSchema: { type: 'object', properties: { game: str('The game\'s id'), server: str('Optional: a server id (default: the pass\'s, else Quick play)'), pass: str('Optional: an agent pass (hap_…) the owner gave; never shown back'), label: str('Optional: the name you play under (default "Claude"; " · AI" is added)'), url: str('Optional: the site (default: this computer\'s preview, else the live site)'), ...STUDIO_ARG }, required: ['game'] },
1054
+ inputSchema: { type: 'object', properties: { game: str('The game\'s id'), server: str('Optional: a server id (default: the pass\'s, else Quick play)'), pass: str('Optional: an agent pass (hap_…) the owner gave; never shown back'), label: str('Optional: the name you play under (default "Claude"; " · AI" is added)'), url: str('Optional: the site (default: this computer\'s preview, else the live site)'), brain: { type: 'string', enum: ['local'], description: 'Optional: "local" lets Cloudflare\'s Clef decision model on this computer (Ollama with clef-flash) choose every few seconds instead of you, free; agent_look shows what it chose and agent_do still overrides it. Never downloads a model: if Ollama has no clef-flash this says what the download costs, and the person decides.' }, ...STUDIO_ARG }, required: ['game'] },
1054
1055
  annotations: { title: 'Sit in a game as an AI guide', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1055
1056
  run: async (a) => {
1056
1057
  const root = ctx.root(a.studio);
1057
1058
  if (ctx.seat && !ctx.seat.closed) await ctx.seat.stand();
1058
1059
  const site = a.url ? String(a.url) : devOf(root)?.url ?? siteUrl(root);
1059
1060
  if (!site) return fail('the studio is not running here and has no live site: preview_run first, or studio_deploy');
1060
- const seat = new AgentSeat({ site, game: String(a.game ?? ''), server: a.server ?? null, pass: a.pass ?? null, label: a.label ? String(a.label).slice(0, 16) : 'Claude', root });
1061
+ let local = null;
1062
+ if (a.brain === 'local') {
1063
+ const found = await detectLocalAi();
1064
+ if (!found.ok) return fail(`No local brain: ${found.say}`, { kind: 'agent', ok: false, error: found.why });
1065
+ local = found;
1066
+ }
1067
+ const seat = new AgentSeat({ site, game: String(a.game ?? ''), server: a.server ?? null, pass: a.pass ?? null, label: a.label ? String(a.label).slice(0, 16) : local ? 'Clef' : 'Claude', root, brain: local ? 'local' : null, local });
1061
1068
  const r = await seat.sit();
1062
1069
  if (!r.ok) return fail(`No seat: ${r.why}`, { kind: 'agent', ok: false, error: r.error ?? null });
1063
1070
  ctx.seat = seat;
1071
+ if (local) return ok(`Seated as ${r.name} in ${r.room} (seat ${r.seat}), thinking with ${local.model} on this computer every few seconds (free; nothing sent to Cloudflare). agent_look shows its last decisions; agent_do overrides; agent_stand ends it.`, { kind: 'agent', ...r });
1064
1072
  return ok(`Seated as ${r.name} in ${r.room} (seat ${r.seat}). ${r.talking ? 'This server lets its AI talk: lines from look.choices.lines, at most one every 8 s.' : 'This server\'s AI does not talk: choose goals only (say null).'} Look again with agent_look; act with agent_do { goal, args, say?, sayArgs? }.\n${JSON.stringify({ view: r.view, asks: r.asks, choices: r.choices }, null, 1).slice(0, 6000)}`, { kind: 'agent', ...r });
1065
1073
  },
1066
1074
  },
package/lib/scaffold.mjs CHANGED
@@ -342,6 +342,13 @@ studio's pinned copy, never a registry lookup of the bare name.
342
342
  it only ASKS. The AI never types: it picks a goal and a line id; the relay drops anything else. With no AI, over
343
343
  budget, or between decisions, the game's \`decide\` plays. \`agent_sit\` (the local MCP) puts the owner's own
344
344
  Claude in a guide's seat.
345
+ - **Clef (0.24.4):** Workers AI's guides think with Cloudflare's decision model \`@cf/cloudflare/clef-flash\` (it
346
+ picks among the vocabulary's goals, values and lines; it never writes text; about 9 neurons a decision).
347
+ \`agents try <id> --view <file> [--ask …] [--model …]\` shows what the brain would decide in one moment. Under
348
+ \`npm run dev\`, Clef on this computer (Ollama with \`clef-flash\`) runs the guides, chat review and game
349
+ decisions for free when it is here; **never download it without the person's yes** (\`ollama pull clef-flash\` is
350
+ about 11 GB). A game whose game.json says \`"decide": true\` may ask \`net.decide(state, questions, { floor })\`
351
+ on the host (\`NETPLAY.md\` section 20): per beat or per turn, never per frame.
345
352
 
346
353
  ## Selling things (the shop)
347
354